Skip to main content

uv_distribution/source/
mod.rs

1//! Fetch and build source distributions from remote sources.
2
3// This is to squash warnings about `|r| r.into_git_reporter()`. Clippy wants
4// me to eta-reduce that and write it as
5// `<(dyn reporter::Reporter + 'static)>::into_git_reporter`
6// instead. But that's a monster. On the other hand, applying this suppression
7// instruction more granularly is annoying. So we just slap it on the module
8// for now. ---AG
9#![expect(clippy::redundant_closure_for_method_calls)]
10
11use std::borrow::Cow;
12use std::ops::Bound;
13use std::path::Path;
14use std::str::FromStr;
15use std::sync::Arc;
16
17use fs_err::tokio as fs;
18use futures::{FutureExt, TryStreamExt};
19use reqwest::{Response, StatusCode};
20use tokio_util::compat::FuturesAsyncReadCompatExt;
21use tracing::{Instrument, debug, info_span, instrument, warn};
22use url::Url;
23
24use uv_auth::CredentialsCache;
25use uv_cache::{Cache, CacheBucket, CacheEntry, CacheShard, Removal, WheelCache};
26use uv_cache_info::CacheInfo;
27use uv_client::{
28    BaseClientBuilder, CacheControl, CachedClientError, Connectivity, DataWithCachePolicy,
29    RegistryClient,
30};
31use uv_configuration::{BuildKind, BuildOutput, NoSources};
32use uv_distribution_filename::{SourceDistExtension, WheelFilename};
33use uv_distribution_types::{
34    BuildInfo, BuildVariables, BuildableSource, ConfigSettings, DirectorySourceUrl,
35    ExtraBuildRequirement, GitDirectorySourceUrl, GitPathSourceUrl, HashPolicy, Hashed, IndexUrl,
36    PathSourceUrl, RemoteSource, RequirementSource, RequiresPython, SourceDist, SourceUrl,
37};
38use uv_extract::hash::Hasher;
39use uv_fs::{Simplified, rename_with_retry, write_atomic};
40use uv_git::{Fetch, GIT_LFS, GitError, GitHttpSettings, GitResolver};
41use uv_git_types::{GitHubRepository, GitOid, GitUrl};
42use uv_metadata::read_archive_metadata;
43use uv_normalize::PackageName;
44use uv_pep440::{Version, release_specifiers_to_ranges};
45use uv_platform_tags::Tags;
46use uv_pypi_types::{HashAlgorithm, HashDigest, HashDigests, PyProjectToml, ResolutionMetadata};
47use uv_redacted::DisplaySafeUrl;
48use uv_types::{BuildContext, BuildKey, BuildStack, SourceBuildTrait};
49use uv_workspace::pyproject::ToolUvSources;
50
51use crate::distribution_database::ManagedClient;
52use crate::error::Error;
53use crate::hash::http_hash_algorithms;
54use crate::metadata::{ArchiveMetadata, GitWorkspaceMember, Metadata};
55use crate::source::built_wheel_metadata::{BuiltWheelFile, BuiltWheelMetadata};
56use crate::source::revision::Revision;
57use crate::{Reporter, RequiresDist};
58
59mod built_wheel_metadata;
60mod revision;
61
62/// Access distribution metadata without requiring a build interpreter.
63///
64/// This is intended for metadata operations that occur before selecting an interpreter, such as
65/// choosing a tool Python from `requires-python`.
66pub struct StaticMetadataDatabase<'a, 'client> {
67    client_builder: &'a BaseClientBuilder<'client>,
68    git: &'a GitResolver,
69    cache: &'a Cache,
70}
71
72/// A direct source tree materialized on disk for static metadata inspection.
73#[derive(Debug)]
74struct MaterializedSourceTree(Box<Path>);
75
76impl MaterializedSourceTree {
77    /// Return the on-disk path for this source tree.
78    fn path(&self) -> &Path {
79        &self.0
80    }
81}
82
83impl<'a, 'client> StaticMetadataDatabase<'a, 'client> {
84    /// Create a [`StaticMetadataDatabase`] for an invocation.
85    pub fn new(
86        client_builder: &'a BaseClientBuilder<'client>,
87        git: &'a GitResolver,
88        cache: &'a Cache,
89    ) -> Self {
90        Self {
91            client_builder,
92            git,
93            cache,
94        }
95    }
96
97    /// Materialize a direct source tree, if the requirement identifies one.
98    ///
99    /// Directory requirements are already materialized. Git source trees are fetched into the
100    /// Git cache and returned at the requested subdirectory.
101    async fn materialize_source_tree(
102        &self,
103        source: &RequirementSource,
104    ) -> Result<Option<MaterializedSourceTree>, Error> {
105        match source {
106            RequirementSource::Directory { install_path, .. } => Ok(Some(MaterializedSourceTree(
107                install_path.to_path_buf().into_boxed_path(),
108            ))),
109            RequirementSource::GitDirectory {
110                git,
111                subdirectory,
112                url,
113            } => {
114                let client = self.client_builder.build()?;
115                let fetch = fetch_git_source_tree(
116                    self.git,
117                    git,
118                    url.to_url(),
119                    subdirectory.as_deref(),
120                    client.git_http_settings(git.url()),
121                    self.cache,
122                    None,
123                )
124                .await?;
125
126                if let Some(subdirectory) = subdirectory {
127                    let source_tree = fetch.path().join(subdirectory);
128                    Ok(Some(MaterializedSourceTree(source_tree.into_boxed_path())))
129                } else {
130                    Ok(Some(MaterializedSourceTree(
131                        fetch.path().to_path_buf().into_boxed_path(),
132                    )))
133                }
134            }
135            _ => Ok(None),
136        }
137    }
138
139    /// Read static [`RequiresPython`] from an already materialized source tree.
140    async fn source_tree_requires_python(
141        &self,
142        source_tree: &MaterializedSourceTree,
143    ) -> Result<Option<RequiresPython>, Error> {
144        let pyproject_toml = match read_pyproject_toml(source_tree.path(), None).await {
145            Ok(pyproject_toml) => pyproject_toml,
146            Err(Error::MissingPyprojectToml) => return Ok(None),
147            Err(err) => return Err(err),
148        };
149
150        match pyproject_toml.requires_python() {
151            Ok(Some(requires_python)) => Ok(Some(RequiresPython::from_specifiers(requires_python))),
152            Ok(None) | Err(uv_pypi_types::MetadataError::FieldNotFound("project")) => Ok(None),
153            Err(uv_pypi_types::MetadataError::DynamicField("requires-python")) => {
154                debug!("Ignoring dynamic `requires-python` in source tree");
155                Ok(None)
156            }
157            Err(err) => Err(Error::PyprojectToml(err)),
158        }
159    }
160
161    /// Read static [`RequiresPython`] from a direct source-tree requirement.
162    pub async fn requires_python(
163        &self,
164        source: &RequirementSource,
165    ) -> Result<Option<RequiresPython>, Error> {
166        let Some(source_tree) = self.materialize_source_tree(source).await? else {
167            return Ok(None);
168        };
169        self.source_tree_requires_python(&source_tree).await
170    }
171}
172
173/// Fetch and validate a Git source tree.
174async fn fetch_git_source_tree(
175    git_resolver: &GitResolver,
176    git: &GitUrl,
177    url: DisplaySafeUrl,
178    subdirectory: Option<&Path>,
179    http_settings: GitHttpSettings,
180    cache: &Cache,
181    reporter: Option<Arc<dyn uv_git::Reporter>>,
182) -> Result<Fetch, Error> {
183    let fetch = git_resolver
184        .fetch(git, http_settings, cache.bucket(CacheBucket::Git), reporter)
185        .await?;
186
187    if let Some(subdirectory) = subdirectory
188        && !fetch.path().join(subdirectory).is_dir()
189    {
190        return Err(Error::MissingSubdirectory(url, subdirectory.to_path_buf()));
191    }
192
193    if git.lfs().enabled() && !fetch.lfs_ready() {
194        if GIT_LFS.is_err() {
195            return Err(Error::MissingSourceDistGitLfsArtifacts(
196                url,
197                GitError::GitLfsNotFound,
198            ));
199        }
200        return Err(Error::MissingSourceDistGitLfsArtifacts(
201            url,
202            GitError::GitLfsNotConfigured,
203        ));
204    }
205
206    Ok(fetch)
207}
208
209/// Fetch and build a source distribution from a remote source, or from a local cache.
210pub(crate) struct SourceDistributionBuilder<'a, T: BuildContext> {
211    build_context: &'a T,
212    build_stack: Option<&'a BuildStack>,
213    reporter: Option<Arc<dyn Reporter>>,
214}
215
216/// The name of the file that contains the revision ID for a remote distribution, encoded via `MsgPack`.
217pub(crate) const HTTP_REVISION: &str = "revision.http";
218
219/// The name of the file that contains the revision ID for a local distribution, encoded via `MsgPack`.
220pub(crate) const LOCAL_REVISION: &str = "revision.rev";
221
222/// The name of the file that contains the cached distribution hashes, encoded via `MsgPack`.
223pub(crate) const HASHES: &str = "hashes.msgpack";
224
225/// The name of the file that contains the cached distribution metadata, encoded via `MsgPack`.
226const METADATA: &str = "metadata.msgpack";
227
228/// The directory within each entry under which to store the unpacked source distribution.
229const SOURCE: &str = "src";
230
231impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
232    /// Initialize a [`SourceDistributionBuilder`] from a [`BuildContext`].
233    pub(crate) fn new(build_context: &'a T) -> Self {
234        Self {
235            build_context,
236            build_stack: None,
237            reporter: None,
238        }
239    }
240
241    /// Set the [`BuildStack`] to use for the [`SourceDistributionBuilder`].
242    #[must_use]
243    pub(crate) fn with_build_stack(self, build_stack: &'a BuildStack) -> Self {
244        Self {
245            build_stack: Some(build_stack),
246            ..self
247        }
248    }
249
250    /// Set the [`Reporter`] to use for the [`SourceDistributionBuilder`].
251    #[must_use]
252    pub(crate) fn with_reporter(self, reporter: Arc<dyn Reporter>) -> Self {
253        Self {
254            reporter: Some(reporter),
255            ..self
256        }
257    }
258
259    /// Download and build a [`SourceDist`].
260    pub(crate) async fn download_and_build(
261        &self,
262        source: &BuildableSource<'_>,
263        tags: &Tags,
264        hashes: HashPolicy<'_>,
265        client: &ManagedClient<'_>,
266    ) -> Result<BuiltWheelMetadata, Error> {
267        let built_wheel_metadata = match &source {
268            BuildableSource::Dist(SourceDist::Registry(dist)) => {
269                // For registry source distributions, shard by package, then version, for
270                // convenience in debugging.
271                let cache_shard = self.build_context.cache().shard(
272                    CacheBucket::SourceDistributions,
273                    WheelCache::Index(&dist.index)
274                        .wheel_dir(dist.name.as_ref())
275                        .join(dist.version.to_string()),
276                );
277
278                let url = dist.file.url.to_url()?;
279
280                // If the URL is a file URL, use the local path directly.
281                if url.scheme() == "file" {
282                    let path = url
283                        .to_file_path()
284                        .map_err(|()| Error::NonFileUrl(url.clone()))?;
285                    return self
286                        .archive(
287                            source,
288                            &PathSourceUrl {
289                                url: &url,
290                                path: Cow::Owned(path),
291                                ext: dist.ext,
292                            },
293                            &cache_shard,
294                            tags,
295                            hashes,
296                        )
297                        .boxed_local()
298                        .await;
299                }
300
301                self.url(
302                    source,
303                    &url,
304                    Some(&dist.index),
305                    &cache_shard,
306                    None,
307                    dist.ext,
308                    tags,
309                    hashes,
310                    client,
311                )
312                .boxed_local()
313                .await?
314            }
315            BuildableSource::Dist(SourceDist::DirectUrl(dist)) => {
316                // For direct URLs, cache directly under the hash of the URL itself.
317                let cache_shard = self.build_context.cache().shard(
318                    CacheBucket::SourceDistributions,
319                    WheelCache::Url(&dist.url).root(),
320                );
321
322                self.url(
323                    source,
324                    &dist.url,
325                    None,
326                    &cache_shard,
327                    dist.subdirectory.as_deref(),
328                    dist.ext,
329                    tags,
330                    hashes,
331                    client,
332                )
333                .boxed_local()
334                .await?
335            }
336            BuildableSource::Dist(SourceDist::GitDirectory(dist)) => {
337                self.git_source_tree(
338                    source,
339                    &GitDirectorySourceUrl::from(dist),
340                    tags,
341                    hashes,
342                    client,
343                )
344                .boxed_local()
345                .await?
346            }
347            BuildableSource::Dist(SourceDist::GitPath(dist)) => {
348                self.git_archive(source, &GitPathSourceUrl::from(dist), tags, hashes, client)
349                    .boxed_local()
350                    .await?
351            }
352            BuildableSource::Dist(SourceDist::Directory(dist)) => {
353                self.source_tree(source, &DirectorySourceUrl::from(dist), tags, hashes)
354                    .boxed_local()
355                    .await?
356            }
357            BuildableSource::Dist(SourceDist::Path(dist)) => {
358                let cache_shard = self.build_context.cache().shard(
359                    CacheBucket::SourceDistributions,
360                    WheelCache::Path(&dist.url).root(),
361                );
362                self.archive(
363                    source,
364                    &PathSourceUrl::from(dist),
365                    &cache_shard,
366                    tags,
367                    hashes,
368                )
369                .boxed_local()
370                .await?
371            }
372            BuildableSource::Url(SourceUrl::Direct(resource)) => {
373                // For direct URLs, cache directly under the hash of the URL itself.
374                let cache_shard = self.build_context.cache().shard(
375                    CacheBucket::SourceDistributions,
376                    WheelCache::Url(resource.url).root(),
377                );
378
379                self.url(
380                    source,
381                    resource.url,
382                    None,
383                    &cache_shard,
384                    resource.subdirectory,
385                    resource.ext,
386                    tags,
387                    hashes,
388                    client,
389                )
390                .boxed_local()
391                .await?
392            }
393            BuildableSource::Url(SourceUrl::GitDirectory(resource)) => {
394                self.git_source_tree(source, resource, tags, hashes, client)
395                    .boxed_local()
396                    .await?
397            }
398            BuildableSource::Url(SourceUrl::GitPath(resource)) => {
399                self.git_archive(source, resource, tags, hashes, client)
400                    .boxed_local()
401                    .await?
402            }
403            BuildableSource::Url(SourceUrl::Directory(resource)) => {
404                self.source_tree(source, resource, tags, hashes)
405                    .boxed_local()
406                    .await?
407            }
408            BuildableSource::Url(SourceUrl::Path(resource)) => {
409                let cache_shard = self.build_context.cache().shard(
410                    CacheBucket::SourceDistributions,
411                    WheelCache::Path(resource.url).root(),
412                );
413                self.archive(source, resource, &cache_shard, tags, hashes)
414                    .boxed_local()
415                    .await?
416            }
417        };
418
419        Ok(built_wheel_metadata)
420    }
421
422    /// Download a [`SourceDist`] and determine its metadata. This typically involves building the
423    /// source distribution into a wheel; however, some build backends support determining the
424    /// metadata without building the source distribution.
425    pub(crate) async fn download_and_build_metadata(
426        &self,
427        source: &BuildableSource<'_>,
428        hashes: HashPolicy<'_>,
429        client: &ManagedClient<'_>,
430    ) -> Result<ArchiveMetadata, Error> {
431        let metadata = match &source {
432            BuildableSource::Dist(SourceDist::Registry(dist)) => {
433                // For registry source distributions, shard by package, then version.
434                let cache_shard = self.build_context.cache().shard(
435                    CacheBucket::SourceDistributions,
436                    WheelCache::Index(&dist.index)
437                        .wheel_dir(dist.name.as_ref())
438                        .join(dist.version.to_string()),
439                );
440
441                let url = dist.file.url.to_url()?;
442
443                // If the URL is a file URL, use the local path directly.
444                if url.scheme() == "file" {
445                    let path = url
446                        .to_file_path()
447                        .map_err(|()| Error::NonFileUrl(url.clone()))?;
448                    return self
449                        .archive_metadata(
450                            source,
451                            &PathSourceUrl {
452                                url: &url,
453                                path: Cow::Owned(path),
454                                ext: dist.ext,
455                            },
456                            &cache_shard,
457                            hashes,
458                        )
459                        .boxed_local()
460                        .await;
461                }
462
463                self.url_metadata(
464                    source,
465                    &url,
466                    Some(&dist.index),
467                    &cache_shard,
468                    None,
469                    dist.ext,
470                    hashes,
471                    client,
472                )
473                .boxed_local()
474                .await?
475            }
476            BuildableSource::Dist(SourceDist::DirectUrl(dist)) => {
477                // For direct URLs, cache directly under the hash of the URL itself.
478                let cache_shard = self.build_context.cache().shard(
479                    CacheBucket::SourceDistributions,
480                    WheelCache::Url(&dist.url).root(),
481                );
482
483                self.url_metadata(
484                    source,
485                    &dist.url,
486                    None,
487                    &cache_shard,
488                    dist.subdirectory.as_deref(),
489                    dist.ext,
490                    hashes,
491                    client,
492                )
493                .boxed_local()
494                .await?
495            }
496            BuildableSource::Dist(SourceDist::GitDirectory(dist)) => {
497                self.git_source_tree_metadata(
498                    source,
499                    &GitDirectorySourceUrl::from(dist),
500                    hashes,
501                    client,
502                    client.unmanaged.credentials_cache(),
503                )
504                .boxed_local()
505                .await?
506            }
507            BuildableSource::Dist(SourceDist::GitPath(dist)) => {
508                self.git_archive_metadata(source, &GitPathSourceUrl::from(dist), hashes, client)
509                    .boxed_local()
510                    .await?
511            }
512            BuildableSource::Dist(SourceDist::Directory(dist)) => {
513                self.source_tree_metadata(
514                    source,
515                    &DirectorySourceUrl::from(dist),
516                    hashes,
517                    client.unmanaged.credentials_cache(),
518                )
519                .boxed_local()
520                .await?
521            }
522            BuildableSource::Dist(SourceDist::Path(dist)) => {
523                let cache_shard = self.build_context.cache().shard(
524                    CacheBucket::SourceDistributions,
525                    WheelCache::Path(&dist.url).root(),
526                );
527                self.archive_metadata(source, &PathSourceUrl::from(dist), &cache_shard, hashes)
528                    .boxed_local()
529                    .await?
530            }
531            BuildableSource::Url(SourceUrl::Direct(resource)) => {
532                // For direct URLs, cache directly under the hash of the URL itself.
533                let cache_shard = self.build_context.cache().shard(
534                    CacheBucket::SourceDistributions,
535                    WheelCache::Url(resource.url).root(),
536                );
537
538                self.url_metadata(
539                    source,
540                    resource.url,
541                    None,
542                    &cache_shard,
543                    resource.subdirectory,
544                    resource.ext,
545                    hashes,
546                    client,
547                )
548                .boxed_local()
549                .await?
550            }
551            BuildableSource::Url(SourceUrl::GitDirectory(resource)) => {
552                self.git_source_tree_metadata(
553                    source,
554                    resource,
555                    hashes,
556                    client,
557                    client.unmanaged.credentials_cache(),
558                )
559                .boxed_local()
560                .await?
561            }
562            BuildableSource::Url(SourceUrl::GitPath(resource)) => {
563                self.git_archive_metadata(source, resource, hashes, client)
564                    .boxed_local()
565                    .await?
566            }
567            BuildableSource::Url(SourceUrl::Directory(resource)) => {
568                self.source_tree_metadata(
569                    source,
570                    resource,
571                    hashes,
572                    client.unmanaged.credentials_cache(),
573                )
574                .boxed_local()
575                .await?
576            }
577            BuildableSource::Url(SourceUrl::Path(resource)) => {
578                let cache_shard = self.build_context.cache().shard(
579                    CacheBucket::SourceDistributions,
580                    WheelCache::Path(resource.url).root(),
581                );
582                self.archive_metadata(source, resource, &cache_shard, hashes)
583                    .boxed_local()
584                    .await?
585            }
586        };
587
588        Ok(metadata)
589    }
590
591    /// Determine the [`ConfigSettings`] for the given package name.
592    fn config_settings_for(&self, name: Option<&PackageName>) -> Cow<'_, ConfigSettings> {
593        if let Some(name) = name {
594            if let Some(package_settings) = self.build_context.config_settings_package().get(name) {
595                Cow::Owned(
596                    package_settings
597                        .clone()
598                        .merge(self.build_context.config_settings().clone()),
599                )
600            } else {
601                Cow::Borrowed(self.build_context.config_settings())
602            }
603        } else {
604            Cow::Borrowed(self.build_context.config_settings())
605        }
606    }
607
608    /// Determine the extra build dependencies for the given package name.
609    fn extra_build_dependencies_for(&self, name: Option<&PackageName>) -> &[ExtraBuildRequirement] {
610        name.and_then(|name| {
611            self.build_context
612                .extra_build_requires()
613                .get(name)
614                .map(Vec::as_slice)
615        })
616        .unwrap_or(&[])
617    }
618
619    /// Determine the extra build variables for the given package name.
620    fn extra_build_variables_for(&self, name: Option<&PackageName>) -> Option<&BuildVariables> {
621        name.and_then(|name| self.build_context.extra_build_variables().get(name))
622    }
623
624    /// Build a source distribution from a remote URL.
625    async fn url<'data>(
626        &self,
627        source: &BuildableSource<'data>,
628        url: &'data DisplaySafeUrl,
629        index: Option<&'data IndexUrl>,
630        cache_shard: &CacheShard,
631        subdirectory: Option<&'data Path>,
632        ext: SourceDistExtension,
633        tags: &Tags,
634        hashes: HashPolicy<'_>,
635        client: &ManagedClient<'_>,
636    ) -> Result<BuiltWheelMetadata, Error> {
637        let _lock = cache_shard.lock().await.map_err(Error::CacheLock)?;
638
639        // Fetch the revision for the source distribution.
640        let revision = self
641            .url_revision(source, ext, url, index, cache_shard, hashes, client)
642            .await?;
643
644        // Before running the build, check that the hashes match.
645        if !revision.satisfies(hashes) {
646            return Err(Error::hash_mismatch(
647                source.to_string(),
648                hashes.digests(),
649                revision.hashes(),
650            ));
651        }
652
653        // Scope all operations to the revision. Within the revision, there's no need to check for
654        // freshness, since entries have to be fresher than the revision itself.
655        let cache_shard = cache_shard.shard(revision.id());
656        let source_dist_entry = cache_shard.entry(SOURCE);
657
658        // We don't track any cache information for URL-based source distributions; they're assumed
659        // to be immutable.
660        let cache_info = CacheInfo::default();
661
662        // If there are build settings or extra build dependencies, we need to scope to a cache shard.
663        let config_settings = self.config_settings_for(source.name());
664        let extra_build_deps = self.extra_build_dependencies_for(source.name());
665        let extra_build_variables = self.extra_build_variables_for(source.name());
666        let build_info = BuildInfo::from_settings(
667            config_settings.into_owned(),
668            extra_build_deps.to_vec(),
669            extra_build_variables.cloned(),
670        );
671        let cache_shard = build_info
672            .cache_shard()
673            .map(|digest| cache_shard.shard(digest))
674            .unwrap_or(cache_shard);
675
676        // If the cache contains a compatible wheel, return it.
677        if let Some(file) = BuiltWheelFile::find_in_cache(tags, &cache_shard)
678            .ok()
679            .flatten()
680            .filter(|file| file.matches(source.name(), source.version()))
681        {
682            return Ok(BuiltWheelMetadata::from_file(
683                file,
684                revision.into_hashes(),
685                cache_info,
686                build_info,
687            ));
688        }
689
690        // Otherwise, we need to build a wheel. Before building, ensure that the source is present.
691        let revision = if source_dist_entry.path().is_dir() {
692            revision
693        } else {
694            self.heal_url_revision(
695                source,
696                ext,
697                url,
698                index,
699                &source_dist_entry,
700                revision,
701                hashes,
702                client,
703            )
704            .await?
705        };
706
707        // Validate that the subdirectory exists.
708        if let Some(subdirectory) = subdirectory {
709            if !source_dist_entry.path().join(subdirectory).is_dir() {
710                return Err(Error::MissingSubdirectory(
711                    url.clone(),
712                    subdirectory.to_path_buf(),
713                ));
714            }
715        }
716
717        let task = self
718            .reporter
719            .as_ref()
720            .map(|reporter| reporter.on_build_start(source));
721
722        // Build the source distribution.
723        let (disk_filename, wheel_filename, metadata) = self
724            .build_distribution(
725                source,
726                source_dist_entry.path(),
727                subdirectory,
728                &cache_shard,
729                NoSources::None,
730            )
731            .await?;
732
733        if let Some(task) = task {
734            if let Some(reporter) = self.reporter.as_ref() {
735                reporter.on_build_complete(source, task);
736            }
737        }
738
739        // Store the metadata.
740        let metadata_entry = cache_shard.entry(METADATA);
741        write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?)
742            .await
743            .map_err(Error::CacheWrite)?;
744
745        Ok(BuiltWheelMetadata {
746            path: cache_shard.join(&disk_filename).into_boxed_path(),
747            target: cache_shard.join(wheel_filename.stem()).into_boxed_path(),
748            filename: wheel_filename,
749            hashes: revision.into_hashes(),
750            cache_info,
751            build_info,
752        })
753    }
754
755    /// Build the source distribution's metadata from a local path.
756    ///
757    /// If the build backend supports `prepare_metadata_for_build_wheel`, this method will avoid
758    /// building the wheel.
759    async fn url_metadata<'data>(
760        &self,
761        source: &BuildableSource<'data>,
762        url: &'data DisplaySafeUrl,
763        index: Option<&'data IndexUrl>,
764        cache_shard: &CacheShard,
765        subdirectory: Option<&'data Path>,
766        ext: SourceDistExtension,
767        hashes: HashPolicy<'_>,
768        client: &ManagedClient<'_>,
769    ) -> Result<ArchiveMetadata, Error> {
770        let _lock = cache_shard.lock().await.map_err(Error::CacheLock)?;
771
772        // Fetch the revision for the source distribution.
773        let revision = self
774            .url_revision(source, ext, url, index, cache_shard, hashes, client)
775            .await?;
776
777        // Before running the build, check that the hashes match.
778        if !revision.satisfies(hashes) {
779            return Err(Error::hash_mismatch(
780                source.to_string(),
781                hashes.digests(),
782                revision.hashes(),
783            ));
784        }
785
786        // Scope all operations to the revision. Within the revision, there's no need to check for
787        // freshness, since entries have to be fresher than the revision itself.
788        let cache_shard = cache_shard.shard(revision.id());
789        let source_dist_entry = cache_shard.entry(SOURCE);
790
791        // If the metadata is static, return it.
792        let dynamic =
793            match StaticMetadata::read(source, source_dist_entry.path(), subdirectory).await? {
794                StaticMetadata::Some(metadata) => {
795                    return Ok(ArchiveMetadata {
796                        metadata: Metadata::from_metadata23(metadata),
797                        hashes: revision.into_hashes(),
798                    });
799                }
800                StaticMetadata::Dynamic => true,
801                StaticMetadata::None => false,
802            };
803
804        // If the cache contains compatible metadata, return it.
805        let metadata_entry = cache_shard.entry(METADATA);
806        match CachedMetadata::read(&metadata_entry).await {
807            Ok(Some(metadata)) => {
808                if metadata.matches(source.name(), source.version()) {
809                    debug!("Using cached metadata for: {source}");
810                    return Ok(ArchiveMetadata {
811                        metadata: Metadata::from_metadata23(metadata.into()),
812                        hashes: revision.into_hashes(),
813                    });
814                }
815                debug!("Cached metadata does not match expected name and version for: {source}");
816            }
817            Ok(None) => {}
818            Err(err) => {
819                debug!("Failed to deserialize cached metadata for: {source} ({err})");
820            }
821        }
822
823        // Otherwise, we need a wheel.
824        let revision = if source_dist_entry.path().is_dir() {
825            revision
826        } else {
827            self.heal_url_revision(
828                source,
829                ext,
830                url,
831                index,
832                &source_dist_entry,
833                revision,
834                hashes,
835                client,
836            )
837            .await?
838        };
839
840        // Validate that the subdirectory exists.
841        if let Some(subdirectory) = subdirectory {
842            if !source_dist_entry.path().join(subdirectory).is_dir() {
843                return Err(Error::MissingSubdirectory(
844                    url.clone(),
845                    subdirectory.to_path_buf(),
846                ));
847            }
848        }
849
850        // Otherwise, we either need to build the metadata.
851        // If the backend supports `prepare_metadata_for_build_wheel`, use it.
852        if let Some(metadata) = self
853            .build_metadata(
854                source,
855                source_dist_entry.path(),
856                subdirectory,
857                NoSources::None,
858            )
859            .boxed_local()
860            .await?
861        {
862            // If necessary, mark the metadata as dynamic.
863            let metadata = if dynamic {
864                ResolutionMetadata {
865                    dynamic: true,
866                    ..metadata
867                }
868            } else {
869                metadata
870            };
871
872            // Store the metadata.
873            fs::create_dir_all(metadata_entry.dir())
874                .await
875                .map_err(Error::CacheWrite)?;
876            write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?)
877                .await
878                .map_err(Error::CacheWrite)?;
879
880            return Ok(ArchiveMetadata {
881                metadata: Metadata::from_metadata23(metadata),
882                hashes: revision.into_hashes(),
883            });
884        }
885
886        // If there are build settings or extra build dependencies, we need to scope to a cache shard.
887        let config_settings = self.config_settings_for(source.name());
888        let extra_build_deps = self.extra_build_dependencies_for(source.name());
889        let extra_build_variables = self.extra_build_variables_for(source.name());
890        let build_info = BuildInfo::from_settings(
891            config_settings.into_owned(),
892            extra_build_deps.to_vec(),
893            extra_build_variables.cloned(),
894        );
895        let cache_shard = build_info
896            .cache_shard()
897            .map(|digest| cache_shard.shard(digest))
898            .unwrap_or(cache_shard);
899
900        let task = self
901            .reporter
902            .as_ref()
903            .map(|reporter| reporter.on_build_start(source));
904
905        // Build the source distribution.
906        let (_disk_filename, _wheel_filename, metadata) = self
907            .build_distribution(
908                source,
909                source_dist_entry.path(),
910                subdirectory,
911                &cache_shard,
912                NoSources::None,
913            )
914            .await?;
915
916        if let Some(task) = task {
917            if let Some(reporter) = self.reporter.as_ref() {
918                reporter.on_build_complete(source, task);
919            }
920        }
921
922        // If necessary, mark the metadata as dynamic.
923        let metadata = if dynamic {
924            ResolutionMetadata {
925                dynamic: true,
926                ..metadata
927            }
928        } else {
929            metadata
930        };
931
932        // Store the metadata.
933        write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?)
934            .await
935            .map_err(Error::CacheWrite)?;
936
937        Ok(ArchiveMetadata {
938            metadata: Metadata::from_metadata23(metadata),
939            hashes: revision.into_hashes(),
940        })
941    }
942
943    /// Return the [`Revision`] for a remote URL, refreshing it if necessary.
944    async fn url_revision(
945        &self,
946        source: &BuildableSource<'_>,
947        ext: SourceDistExtension,
948        url: &DisplaySafeUrl,
949        index: Option<&IndexUrl>,
950        cache_shard: &CacheShard,
951        hashes: HashPolicy<'_>,
952        client: &ManagedClient<'_>,
953    ) -> Result<Revision, Error> {
954        let cache_entry = cache_shard.entry(HTTP_REVISION);
955
956        // Determine the cache control policy for the request.
957        let cache_control = match client.unmanaged.connectivity() {
958            Connectivity::Online
959                if let Some(header) = index.and_then(|index| {
960                    self.build_context
961                        .locations()
962                        .artifact_cache_control_for(index)
963                }) =>
964            {
965                CacheControl::Override(header)
966            }
967            Connectivity::Online => CacheControl::from(
968                self.build_context
969                    .cache()
970                    .freshness(&cache_entry, source.name(), source.source_tree())
971                    .map_err(Error::CacheRead)?,
972            ),
973            Connectivity::Offline => CacheControl::AllowStale,
974        };
975
976        let download = |response| {
977            async {
978                // At this point, we're seeing a new or updated source distribution. Initialize a
979                // new revision, to collect the source and built artifacts.
980                let revision = Revision::new();
981
982                // Download the source distribution.
983                debug!("Downloading source distribution: {source}");
984                let entry = cache_shard.shard(revision.id()).entry(SOURCE);
985                let algorithms = http_hash_algorithms(hashes);
986                let (hashes, size) = self
987                    .download_archive(response, source, ext, entry.path(), &algorithms)
988                    .await?;
989
990                Ok(revision
991                    .with_hashes(HashDigests::from(hashes))
992                    .with_size(size))
993            }
994            .boxed_local()
995            .instrument(info_span!("download", source_dist = %source))
996        };
997        let req = Self::request(url.clone(), client.unmanaged)?;
998        let revision = client
999            .managed(|client| {
1000                client.cached_client().get_serde_with_retry(
1001                    req,
1002                    &cache_entry,
1003                    cache_control.clone(),
1004                    download,
1005                )
1006            })
1007            .await
1008            .map_err(|err| match err {
1009                CachedClientError::Callback { err, .. } => err,
1010                CachedClientError::Client(err) => Error::Client(err),
1011            })?;
1012
1013        let expected_size = match source {
1014            BuildableSource::Dist(SourceDist::Registry(dist)) if dist.size_is_authoritative => {
1015                dist.size()
1016            }
1017            BuildableSource::Dist(SourceDist::DirectUrl(dist)) => dist.size(),
1018            _ => None,
1019        };
1020        if let (Some(expected), Some(actual)) = (expected_size, revision.size())
1021            && expected != actual
1022        {
1023            return Err(Error::MismatchedSize {
1024                distribution: source.to_string(),
1025                expected,
1026                actual,
1027            });
1028        }
1029
1030        // If the archive is missing the required hashes or size, force a refresh.
1031        if revision.has_digests(hashes) && (expected_size.is_none() || revision.size().is_some()) {
1032            Ok(revision)
1033        } else {
1034            client
1035                .managed(async |client| {
1036                    client
1037                        .cached_client()
1038                        .skip_cache_with_retry(
1039                            Self::request(url.clone(), client)?,
1040                            &cache_entry,
1041                            cache_control,
1042                            download,
1043                        )
1044                        .await
1045                        .map_err(|err| match err {
1046                            CachedClientError::Callback { err, .. } => err,
1047                            CachedClientError::Client(err) => Error::Client(err),
1048                        })
1049                })
1050                .await
1051        }
1052    }
1053
1054    /// Build a source distribution from a local archive (e.g., `.tar.gz` or `.zip`).
1055    async fn archive(
1056        &self,
1057        source: &BuildableSource<'_>,
1058        resource: &PathSourceUrl<'_>,
1059        cache_shard: &CacheShard,
1060        tags: &Tags,
1061        hashes: HashPolicy<'_>,
1062    ) -> Result<BuiltWheelMetadata, Error> {
1063        let _lock = cache_shard.lock().await.map_err(Error::CacheLock)?;
1064
1065        // Fetch the revision for the source distribution.
1066        let LocalRevisionPointer {
1067            cache_info,
1068            revision,
1069        } = self
1070            .archive_revision(source, resource, cache_shard, hashes)
1071            .await?;
1072
1073        // Before running the build, check that the hashes match.
1074        if !revision.satisfies(hashes) {
1075            return Err(Error::hash_mismatch(
1076                source.to_string(),
1077                hashes.digests(),
1078                revision.hashes(),
1079            ));
1080        }
1081
1082        // Scope all operations to the revision. Within the revision, there's no need to check for
1083        // freshness, since entries have to be fresher than the revision itself.
1084        let cache_shard = cache_shard.shard(revision.id());
1085        let source_entry = cache_shard.entry(SOURCE);
1086
1087        // If there are build settings or extra build dependencies, we need to scope to a cache shard.
1088        let config_settings = self.config_settings_for(source.name());
1089        let extra_build_deps = self.extra_build_dependencies_for(source.name());
1090        let extra_build_variables = self.extra_build_variables_for(source.name());
1091        let build_info = BuildInfo::from_settings(
1092            config_settings.into_owned(),
1093            extra_build_deps.to_vec(),
1094            extra_build_variables.cloned(),
1095        );
1096        let cache_shard = build_info
1097            .cache_shard()
1098            .map(|digest| cache_shard.shard(digest))
1099            .unwrap_or(cache_shard);
1100
1101        // If the cache contains a compatible wheel, return it.
1102        if let Some(file) = BuiltWheelFile::find_in_cache(tags, &cache_shard)
1103            .ok()
1104            .flatten()
1105            .filter(|file| file.matches(source.name(), source.version()))
1106        {
1107            return Ok(BuiltWheelMetadata::from_file(
1108                file,
1109                revision.into_hashes(),
1110                cache_info,
1111                build_info,
1112            ));
1113        }
1114
1115        // Otherwise, we need to build a wheel, which requires a source distribution.
1116        let revision = if source_entry.path().is_dir() {
1117            revision
1118        } else {
1119            self.heal_archive_revision(source, resource, &source_entry, revision, hashes)
1120                .await?
1121        };
1122
1123        let task = self
1124            .reporter
1125            .as_ref()
1126            .map(|reporter| reporter.on_build_start(source));
1127
1128        let (disk_filename, filename, metadata) = self
1129            .build_distribution(
1130                source,
1131                source_entry.path(),
1132                None,
1133                &cache_shard,
1134                NoSources::None,
1135            )
1136            .await?;
1137
1138        if let Some(task) = task {
1139            if let Some(reporter) = self.reporter.as_ref() {
1140                reporter.on_build_complete(source, task);
1141            }
1142        }
1143
1144        // Store the metadata.
1145        let metadata_entry = cache_shard.entry(METADATA);
1146        write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?)
1147            .await
1148            .map_err(Error::CacheWrite)?;
1149
1150        Ok(BuiltWheelMetadata {
1151            path: cache_shard.join(&disk_filename).into_boxed_path(),
1152            target: cache_shard.join(filename.stem()).into_boxed_path(),
1153            filename,
1154            hashes: revision.into_hashes(),
1155            cache_info,
1156            build_info,
1157        })
1158    }
1159
1160    /// Build the source distribution's metadata from a local archive (e.g., `.tar.gz` or `.zip`).
1161    ///
1162    /// If the build backend supports `prepare_metadata_for_build_wheel`, this method will avoid
1163    /// building the wheel.
1164    async fn archive_metadata(
1165        &self,
1166        source: &BuildableSource<'_>,
1167        resource: &PathSourceUrl<'_>,
1168        cache_shard: &CacheShard,
1169        hashes: HashPolicy<'_>,
1170    ) -> Result<ArchiveMetadata, Error> {
1171        let _lock = cache_shard.lock().await.map_err(Error::CacheLock)?;
1172
1173        // Fetch the revision for the source distribution.
1174        let LocalRevisionPointer { revision, .. } = self
1175            .archive_revision(source, resource, cache_shard, hashes)
1176            .await?;
1177
1178        // Before running the build, check that the hashes match.
1179        if !revision.satisfies(hashes) {
1180            return Err(Error::hash_mismatch(
1181                source.to_string(),
1182                hashes.digests(),
1183                revision.hashes(),
1184            ));
1185        }
1186
1187        // Scope all operations to the revision. Within the revision, there's no need to check for
1188        // freshness, since entries have to be fresher than the revision itself.
1189        let cache_shard = cache_shard.shard(revision.id());
1190        let source_entry = cache_shard.entry(SOURCE);
1191
1192        // If the metadata is static, return it.
1193        let dynamic = match StaticMetadata::read(source, source_entry.path(), None).await? {
1194            StaticMetadata::Some(metadata) => {
1195                return Ok(ArchiveMetadata {
1196                    metadata: Metadata::from_metadata23(metadata),
1197                    hashes: revision.into_hashes(),
1198                });
1199            }
1200            StaticMetadata::Dynamic => true,
1201            StaticMetadata::None => false,
1202        };
1203
1204        // If the cache contains compatible metadata, return it.
1205        let metadata_entry = cache_shard.entry(METADATA);
1206        match CachedMetadata::read(&metadata_entry).await {
1207            Ok(Some(metadata)) => {
1208                if metadata.matches(source.name(), source.version()) {
1209                    debug!("Using cached metadata for: {source}");
1210                    return Ok(ArchiveMetadata {
1211                        metadata: Metadata::from_metadata23(metadata.into()),
1212                        hashes: revision.into_hashes(),
1213                    });
1214                }
1215                debug!("Cached metadata does not match expected name and version for: {source}");
1216            }
1217            Ok(None) => {}
1218            Err(err) => {
1219                debug!("Failed to deserialize cached metadata for: {source} ({err})");
1220            }
1221        }
1222
1223        // Otherwise, we need a source distribution.
1224        let revision = if source_entry.path().is_dir() {
1225            revision
1226        } else {
1227            self.heal_archive_revision(source, resource, &source_entry, revision, hashes)
1228                .await?
1229        };
1230
1231        // If the backend supports `prepare_metadata_for_build_wheel`, use it.
1232        if let Some(metadata) = self
1233            .build_metadata(source, source_entry.path(), None, NoSources::None)
1234            .boxed_local()
1235            .await?
1236        {
1237            // If necessary, mark the metadata as dynamic.
1238            let metadata = if dynamic {
1239                ResolutionMetadata {
1240                    dynamic: true,
1241                    ..metadata
1242                }
1243            } else {
1244                metadata
1245            };
1246
1247            // Store the metadata.
1248            fs::create_dir_all(metadata_entry.dir())
1249                .await
1250                .map_err(Error::CacheWrite)?;
1251            write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?)
1252                .await
1253                .map_err(Error::CacheWrite)?;
1254
1255            return Ok(ArchiveMetadata {
1256                metadata: Metadata::from_metadata23(metadata),
1257                hashes: revision.into_hashes(),
1258            });
1259        }
1260
1261        // If there are build settings or extra build dependencies, we need to scope to a cache shard.
1262        let config_settings = self.config_settings_for(source.name());
1263        let extra_build_deps = self.extra_build_dependencies_for(source.name());
1264        let extra_build_variables = self.extra_build_variables_for(source.name());
1265        let build_info = BuildInfo::from_settings(
1266            config_settings.into_owned(),
1267            extra_build_deps.to_vec(),
1268            extra_build_variables.cloned(),
1269        );
1270        let cache_shard = build_info
1271            .cache_shard()
1272            .map(|digest| cache_shard.shard(digest))
1273            .unwrap_or(cache_shard);
1274
1275        // Otherwise, we need to build a wheel.
1276        let task = self
1277            .reporter
1278            .as_ref()
1279            .map(|reporter| reporter.on_build_start(source));
1280
1281        let (_disk_filename, _filename, metadata) = self
1282            .build_distribution(
1283                source,
1284                source_entry.path(),
1285                None,
1286                &cache_shard,
1287                NoSources::None,
1288            )
1289            .await?;
1290
1291        if let Some(task) = task {
1292            if let Some(reporter) = self.reporter.as_ref() {
1293                reporter.on_build_complete(source, task);
1294            }
1295        }
1296
1297        // If necessary, mark the metadata as dynamic.
1298        let metadata = if dynamic {
1299            ResolutionMetadata {
1300                dynamic: true,
1301                ..metadata
1302            }
1303        } else {
1304            metadata
1305        };
1306
1307        // Store the metadata.
1308        write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?)
1309            .await
1310            .map_err(Error::CacheWrite)?;
1311
1312        Ok(ArchiveMetadata {
1313            metadata: Metadata::from_metadata23(metadata),
1314            hashes: revision.into_hashes(),
1315        })
1316    }
1317
1318    /// Return the [`Revision`] for a local archive, refreshing it if necessary.
1319    async fn archive_revision(
1320        &self,
1321        source: &BuildableSource<'_>,
1322        resource: &PathSourceUrl<'_>,
1323        cache_shard: &CacheShard,
1324        hashes: HashPolicy<'_>,
1325    ) -> Result<LocalRevisionPointer, Error> {
1326        // Verify that the archive exists.
1327        if !resource.path.is_file() {
1328            return Err(Error::NotFound(resource.url.clone()));
1329        }
1330
1331        // Determine the last-modified time of the source distribution.
1332        let cache_info = CacheInfo::from_file(&resource.path).map_err(Error::CacheRead)?;
1333
1334        // Read the existing metadata from the cache.
1335        let revision_entry = cache_shard.entry(LOCAL_REVISION);
1336
1337        // If the revision already exists, return it. There's no need to check for freshness, since
1338        // we use an exact timestamp.
1339        if let Some(pointer) = LocalRevisionPointer::read_from(&revision_entry)? {
1340            if *pointer.cache_info() == cache_info {
1341                if pointer.revision().has_digests(hashes) {
1342                    return Ok(pointer);
1343                }
1344            }
1345        }
1346
1347        // Otherwise, we need to create a new revision.
1348        let revision = Revision::new();
1349
1350        // Unzip the archive to a temporary directory.
1351        debug!("Unpacking source distribution: {source}");
1352        let entry = cache_shard.shard(revision.id()).entry(SOURCE);
1353        let algorithms = hashes.algorithms();
1354        let hashes = self
1355            .persist_archive(&resource.path, resource.ext, entry.path(), &algorithms)
1356            .await?;
1357
1358        // Include the hashes and cache info in the revision.
1359        let revision = revision.with_hashes(HashDigests::from(hashes));
1360
1361        // Persist the revision.
1362        let pointer = LocalRevisionPointer {
1363            cache_info,
1364            revision,
1365        };
1366        pointer.write_to(&revision_entry).await?;
1367
1368        Ok(pointer)
1369    }
1370
1371    /// Build a source distribution from a local source tree (i.e., directory), either editable or
1372    /// non-editable.
1373    async fn source_tree(
1374        &self,
1375        source: &BuildableSource<'_>,
1376        resource: &DirectorySourceUrl<'_>,
1377        tags: &Tags,
1378        hashes: HashPolicy<'_>,
1379    ) -> Result<BuiltWheelMetadata, Error> {
1380        // Before running the build, check that the hashes match.
1381        if hashes.requires_validation() {
1382            return Err(Error::HashesNotSupportedSourceTree(source.to_string()));
1383        }
1384
1385        let cache_shard = self.build_context.cache().shard(
1386            CacheBucket::SourceDistributions,
1387            if resource.editable.unwrap_or(false) {
1388                WheelCache::Editable(resource.url).root()
1389            } else {
1390                WheelCache::Path(resource.url).root()
1391            },
1392        );
1393
1394        // Acquire the advisory lock.
1395        let _lock = cache_shard.lock().await.map_err(Error::CacheLock)?;
1396
1397        // Fetch the revision for the source distribution.
1398        let LocalRevisionPointer {
1399            cache_info,
1400            revision,
1401        } = self
1402            .source_tree_revision(source, resource, &cache_shard)
1403            .await?;
1404
1405        // Scope all operations to the revision. Within the revision, there's no need to check for
1406        // freshness, since entries have to be fresher than the revision itself.
1407        let cache_shard = cache_shard.shard(revision.id());
1408
1409        // If there are build settings or extra build dependencies, we need to scope to a cache shard.
1410        let config_settings = self.config_settings_for(source.name());
1411        let extra_build_deps = self.extra_build_dependencies_for(source.name());
1412        let extra_build_variables = self.extra_build_variables_for(source.name());
1413        let build_info = BuildInfo::from_settings(
1414            config_settings.into_owned(),
1415            extra_build_deps.to_vec(),
1416            extra_build_variables.cloned(),
1417        );
1418        let cache_shard = build_info
1419            .cache_shard()
1420            .map(|digest| cache_shard.shard(digest))
1421            .unwrap_or(cache_shard);
1422
1423        // If the cache contains a compatible wheel, return it.
1424        if let Some(file) = BuiltWheelFile::find_in_cache(tags, &cache_shard)
1425            .ok()
1426            .flatten()
1427            .filter(|file| file.matches(source.name(), source.version()))
1428        {
1429            return Ok(BuiltWheelMetadata::from_file(
1430                file,
1431                revision.into_hashes(),
1432                cache_info,
1433                build_info,
1434            ));
1435        }
1436
1437        // Otherwise, we need to build a wheel.
1438        let task = self
1439            .reporter
1440            .as_ref()
1441            .map(|reporter| reporter.on_build_start(source));
1442
1443        let (disk_filename, filename, metadata) = self
1444            .build_distribution(
1445                source,
1446                resource.install_path,
1447                None,
1448                &cache_shard,
1449                self.build_context.sources().clone(),
1450            )
1451            .await?;
1452
1453        if let Some(task) = task {
1454            if let Some(reporter) = self.reporter.as_ref() {
1455                reporter.on_build_complete(source, task);
1456            }
1457        }
1458
1459        // Store the metadata.
1460        let metadata_entry = cache_shard.entry(METADATA);
1461        write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?)
1462            .await
1463            .map_err(Error::CacheWrite)?;
1464
1465        Ok(BuiltWheelMetadata {
1466            path: cache_shard.join(&disk_filename).into_boxed_path(),
1467            target: cache_shard.join(filename.stem()).into_boxed_path(),
1468            filename,
1469            hashes: revision.into_hashes(),
1470            cache_info,
1471            build_info,
1472        })
1473    }
1474
1475    /// Build the source distribution's metadata from a local source tree (i.e., a directory),
1476    /// either editable or non-editable.
1477    ///
1478    /// If the build backend supports `prepare_metadata_for_build_wheel`, this method will avoid
1479    /// building the wheel.
1480    async fn source_tree_metadata(
1481        &self,
1482        source: &BuildableSource<'_>,
1483        resource: &DirectorySourceUrl<'_>,
1484        hashes: HashPolicy<'_>,
1485        credentials_cache: &CredentialsCache,
1486    ) -> Result<ArchiveMetadata, Error> {
1487        // Before running the build, check that the hashes match.
1488        if hashes.requires_validation() {
1489            return Err(Error::HashesNotSupportedSourceTree(source.to_string()));
1490        }
1491
1492        // Project-style resolution always lowers workspace members as editable. Tool-style
1493        // resolution preserves an explicit local requirement choice instead, defaulting implicit
1494        // workspace siblings to non-editable.
1495        let editable = self
1496            .build_context
1497            .source_tree_editable_policy()
1498            .workspace_member_editable(resource.editable);
1499
1500        // If the metadata is static, return it.
1501        let dynamic = match StaticMetadata::read(source, resource.install_path, None).await? {
1502            StaticMetadata::Some(metadata) => {
1503                return Ok(ArchiveMetadata::from(
1504                    Metadata::from_workspace(
1505                        metadata,
1506                        resource.install_path,
1507                        None,
1508                        self.build_context.locations(),
1509                        self.build_context.sources().clone(),
1510                        editable,
1511                        self.build_context.cache(),
1512                        self.build_context.workspace_cache(),
1513                        credentials_cache,
1514                    )
1515                    .await?,
1516                ));
1517            }
1518            StaticMetadata::Dynamic => true,
1519            StaticMetadata::None => false,
1520        };
1521
1522        let cache_shard = self.build_context.cache().shard(
1523            CacheBucket::SourceDistributions,
1524            if resource.editable.unwrap_or(false) {
1525                WheelCache::Editable(resource.url).root()
1526            } else {
1527                WheelCache::Path(resource.url).root()
1528            },
1529        );
1530
1531        // Acquire the advisory lock.
1532        let _lock = cache_shard.lock().await.map_err(Error::CacheLock)?;
1533
1534        // Fetch the revision for the source distribution.
1535        let LocalRevisionPointer { revision, .. } = self
1536            .source_tree_revision(source, resource, &cache_shard)
1537            .await?;
1538
1539        // Scope all operations to the revision. Within the revision, there's no need to check for
1540        // freshness, since entries have to be fresher than the revision itself.
1541        let cache_shard = cache_shard.shard(revision.id());
1542
1543        // If the cache contains compatible metadata, return it.
1544        let metadata_entry = cache_shard.entry(METADATA);
1545        match CachedMetadata::read(&metadata_entry).await {
1546            Ok(Some(metadata)) => {
1547                if metadata.matches(source.name(), source.version()) {
1548                    debug!("Using cached metadata for: {source}");
1549
1550                    // If necessary, mark the metadata as dynamic.
1551                    let metadata = if dynamic {
1552                        ResolutionMetadata {
1553                            dynamic: true,
1554                            ..metadata.into()
1555                        }
1556                    } else {
1557                        metadata.into()
1558                    };
1559                    return Ok(ArchiveMetadata::from(
1560                        Metadata::from_workspace(
1561                            metadata,
1562                            resource.install_path,
1563                            None,
1564                            self.build_context.locations(),
1565                            self.build_context.sources().clone(),
1566                            editable,
1567                            self.build_context.cache(),
1568                            self.build_context.workspace_cache(),
1569                            credentials_cache,
1570                        )
1571                        .await?,
1572                    ));
1573                }
1574                debug!("Cached metadata does not match expected name and version for: {source}");
1575            }
1576            Ok(None) => {}
1577            Err(err) => {
1578                debug!("Failed to deserialize cached metadata for: {source} ({err})");
1579            }
1580        }
1581
1582        // If the backend supports `prepare_metadata_for_build_wheel`, use it.
1583        if let Some(metadata) = self
1584            .build_metadata(
1585                source,
1586                resource.install_path,
1587                None,
1588                self.build_context.sources().clone(),
1589            )
1590            .boxed_local()
1591            .await?
1592        {
1593            // Store the metadata.
1594            fs::create_dir_all(metadata_entry.dir())
1595                .await
1596                .map_err(Error::CacheWrite)?;
1597            write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?)
1598                .await
1599                .map_err(Error::CacheWrite)?;
1600
1601            // If necessary, mark the metadata as dynamic.
1602            let metadata = if dynamic {
1603                ResolutionMetadata {
1604                    dynamic: true,
1605                    ..metadata
1606                }
1607            } else {
1608                metadata
1609            };
1610
1611            return Ok(ArchiveMetadata::from(
1612                Metadata::from_workspace(
1613                    metadata,
1614                    resource.install_path,
1615                    None,
1616                    self.build_context.locations(),
1617                    self.build_context.sources().clone(),
1618                    editable,
1619                    self.build_context.cache(),
1620                    self.build_context.workspace_cache(),
1621                    credentials_cache,
1622                )
1623                .await?,
1624            ));
1625        }
1626
1627        // If there are build settings or extra build dependencies, we need to scope to a cache shard.
1628        let config_settings = self.config_settings_for(source.name());
1629        let extra_build_deps = self.extra_build_dependencies_for(source.name());
1630        let extra_build_variables = self.extra_build_variables_for(source.name());
1631        let build_info = BuildInfo::from_settings(
1632            config_settings.into_owned(),
1633            extra_build_deps.to_vec(),
1634            extra_build_variables.cloned(),
1635        );
1636        let cache_shard = build_info
1637            .cache_shard()
1638            .map(|digest| cache_shard.shard(digest))
1639            .unwrap_or(cache_shard);
1640
1641        // Otherwise, we need to build a wheel.
1642        let task = self
1643            .reporter
1644            .as_ref()
1645            .map(|reporter| reporter.on_build_start(source));
1646
1647        let (_disk_filename, _filename, metadata) = self
1648            .build_distribution(
1649                source,
1650                resource.install_path,
1651                None,
1652                &cache_shard,
1653                self.build_context.sources().clone(),
1654            )
1655            .await?;
1656
1657        if let Some(task) = task {
1658            if let Some(reporter) = self.reporter.as_ref() {
1659                reporter.on_build_complete(source, task);
1660            }
1661        }
1662
1663        // Store the metadata.
1664        write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?)
1665            .await
1666            .map_err(Error::CacheWrite)?;
1667
1668        // If necessary, mark the metadata as dynamic.
1669        let metadata = if dynamic {
1670            ResolutionMetadata {
1671                dynamic: true,
1672                ..metadata
1673            }
1674        } else {
1675            metadata
1676        };
1677
1678        Ok(ArchiveMetadata::from(
1679            Metadata::from_workspace(
1680                metadata,
1681                resource.install_path,
1682                None,
1683                self.build_context.locations(),
1684                self.build_context.sources().clone(),
1685                editable,
1686                self.build_context.cache(),
1687                self.build_context.workspace_cache(),
1688                credentials_cache,
1689            )
1690            .await?,
1691        ))
1692    }
1693
1694    /// Return the [`Revision`] for a local source tree, refreshing it if necessary.
1695    async fn source_tree_revision(
1696        &self,
1697        source: &BuildableSource<'_>,
1698        resource: &DirectorySourceUrl<'_>,
1699        cache_shard: &CacheShard,
1700    ) -> Result<LocalRevisionPointer, Error> {
1701        // Verify that the source tree exists.
1702        if !resource.install_path.is_dir() {
1703            return Err(Error::NotFound(resource.url.clone()));
1704        }
1705
1706        // Determine the last-modified time of the source distribution.
1707        let cache_info = CacheInfo::from_directory(resource.install_path)?;
1708
1709        // Read the existing metadata from the cache.
1710        let entry = cache_shard.entry(LOCAL_REVISION);
1711
1712        // If the revision is fresh, return it.
1713        if self
1714            .build_context
1715            .cache()
1716            .freshness(&entry, source.name(), source.source_tree())
1717            .map_err(Error::CacheRead)?
1718            .is_fresh()
1719        {
1720            match LocalRevisionPointer::read_from(&entry) {
1721                Ok(Some(pointer)) => {
1722                    if *pointer.cache_info() == cache_info {
1723                        return Ok(pointer);
1724                    }
1725
1726                    debug!("Cached revision does not match expected cache info for: {source}");
1727                }
1728                Ok(None) => {}
1729                Err(err) => {
1730                    debug!("Failed to deserialize cached revision for: {source} ({err})");
1731                }
1732            }
1733        }
1734
1735        // Otherwise, we need to create a new revision.
1736        let revision = Revision::new();
1737        let pointer = LocalRevisionPointer {
1738            cache_info,
1739            revision,
1740        };
1741        pointer.write_to(&entry).await?;
1742
1743        Ok(pointer)
1744    }
1745
1746    /// Return the [`RequiresDist`] from a `pyproject.toml`, if it can be statically extracted.
1747    pub(crate) async fn source_tree_requires_dist(
1748        &self,
1749        path: &Path,
1750        pyproject_toml: &PyProjectToml,
1751        credentials_cache: &CredentialsCache,
1752    ) -> Result<Option<RequiresDist>, Error> {
1753        // Attempt to read static metadata from the `pyproject.toml`.
1754        match uv_pypi_types::RequiresDist::from_pyproject_toml(pyproject_toml.clone()) {
1755            Ok(requires_dist) => {
1756                debug!("Found static `requires-dist` for: {}", path.display());
1757                let requires_dist = RequiresDist::from_project_maybe_workspace(
1758                    requires_dist,
1759                    path,
1760                    None,
1761                    self.build_context.locations(),
1762                    self.build_context.sources().clone(),
1763                    self.build_context
1764                        .source_tree_editable_policy()
1765                        .workspace_member_editable(None),
1766                    self.build_context.cache(),
1767                    self.build_context.workspace_cache(),
1768                    credentials_cache,
1769                )
1770                .await?;
1771                Ok(Some(requires_dist))
1772            }
1773            Err(
1774                err @ (uv_pypi_types::MetadataError::Pep508Error(_)
1775                | uv_pypi_types::MetadataError::DynamicField(_)
1776                | uv_pypi_types::MetadataError::FieldNotFound(_)
1777                | uv_pypi_types::MetadataError::PoetrySyntax),
1778            ) => {
1779                debug!(
1780                    "No static `requires-dist` available for: {} ({err:?})",
1781                    path.display()
1782                );
1783                Ok(None)
1784            }
1785            Err(err) => Err(Error::PyprojectToml(err)),
1786        }
1787    }
1788
1789    /// Return the [`RevisionHashes`] for an archive stored in a Git repository.
1790    async fn git_archive_revision(
1791        &self,
1792        source: &BuildableSource<'_>,
1793        resource: &GitPathSourceUrl<'_>,
1794        fetch: &Fetch,
1795        cache_shard: &CacheShard,
1796        hashes: HashPolicy<'_>,
1797    ) -> Result<RevisionHashes, Error> {
1798        // Validate that LFS artifacts were fully initialized.
1799        if resource.git.lfs().enabled() && !fetch.lfs_ready() {
1800            if GIT_LFS.is_err() {
1801                return Err(Error::MissingSourceDistGitLfsArtifacts(
1802                    resource.url.to_url(),
1803                    GitError::GitLfsNotFound,
1804                ));
1805            }
1806            return Err(Error::MissingSourceDistGitLfsArtifacts(
1807                resource.url.to_url(),
1808                GitError::GitLfsNotConfigured,
1809            ));
1810        }
1811
1812        // Verify that the archive exists.
1813        let install_path = fetch.path().join(&resource.path);
1814        if !install_path.is_file() {
1815            return Err(Error::NotFound(resource.url.to_url()));
1816        }
1817
1818        // Read the existing metadata from the cache.
1819        let revision_entry = cache_shard.entry(HASHES);
1820
1821        // If the revision already exists, return it. There's no need to check for freshness, since
1822        // everything is scoped to a Git commit.
1823        if let Some(revision) = RevisionHashes::read_from(&revision_entry)? {
1824            if revision.has_digests(hashes) {
1825                return Ok(revision);
1826            }
1827        }
1828
1829        // Otherwise, we need to unzip the archive, or at least compute the hashes.
1830        debug!("Unpacking source distribution: {source}");
1831        let entry = cache_shard.entry(SOURCE);
1832        let algorithms = hashes.algorithms();
1833        let hashes = self
1834            .persist_archive(&install_path, resource.ext, entry.path(), &algorithms)
1835            .await?;
1836
1837        // Persist the revision.
1838        let revision = RevisionHashes { hashes };
1839        revision.write_to(&revision_entry).await?;
1840
1841        Ok(revision)
1842    }
1843
1844    /// Build a source distribution from a Git repository.
1845    async fn git_archive(
1846        &self,
1847        source: &BuildableSource<'_>,
1848        resource: &GitPathSourceUrl<'_>,
1849        tags: &Tags,
1850        hashes: HashPolicy<'_>,
1851        client: &ManagedClient<'_>,
1852    ) -> Result<BuiltWheelMetadata, Error> {
1853        // Fetch the Git repository.
1854        let fetch = self
1855            .build_context
1856            .git()
1857            .fetch(
1858                resource.git,
1859                client.unmanaged.git_http_settings(resource.git.url()),
1860                self.build_context.cache().bucket(CacheBucket::Git),
1861                self.reporter
1862                    .clone()
1863                    .map(|reporter| reporter.into_git_reporter()),
1864            )
1865            .await?;
1866
1867        let git_sha = fetch.git().precise().expect("Exact commit after checkout");
1868        let cache_shard = self.build_context.cache().shard(
1869            CacheBucket::SourceDistributions,
1870            WheelCache::Git(resource.url, git_sha.as_short_str()).root(),
1871        );
1872
1873        // Fetch the revision for the source distribution.
1874        let revision = self
1875            .git_archive_revision(source, resource, &fetch, &cache_shard, hashes)
1876            .await?;
1877
1878        // Before running the build, check that the hashes match.
1879        if !revision.satisfies(hashes) {
1880            return Err(Error::hash_mismatch(
1881                source.to_string(),
1882                hashes.digests(),
1883                revision.hashes(),
1884            ));
1885        }
1886
1887        let source_entry = cache_shard.entry(SOURCE);
1888
1889        // If there are build settings or extra build dependencies, we need to scope to a cache shard.
1890        let config_settings = self.config_settings_for(source.name());
1891        let extra_build_deps = self.extra_build_dependencies_for(source.name());
1892        let extra_build_variables = self.extra_build_variables_for(source.name());
1893        let build_info = BuildInfo::from_settings(
1894            config_settings.into_owned(),
1895            extra_build_deps.to_vec(),
1896            extra_build_variables.cloned(),
1897        );
1898        let cache_shard = build_info
1899            .cache_shard()
1900            .map(|digest| cache_shard.shard(digest))
1901            .unwrap_or(cache_shard);
1902
1903        // If the cache contains a compatible wheel, return it.
1904        if let Some(file) = BuiltWheelFile::find_in_cache(tags, &cache_shard)
1905            .ok()
1906            .flatten()
1907            .filter(|file| file.matches(source.name(), source.version()))
1908        {
1909            return Ok(BuiltWheelMetadata::from_file(
1910                file,
1911                revision.into_hashes(),
1912                CacheInfo::default(),
1913                build_info,
1914            ));
1915        }
1916
1917        // Otherwise, we need to build a wheel.
1918        let task = self
1919            .reporter
1920            .as_ref()
1921            .map(|reporter| reporter.on_build_start(source));
1922
1923        let (disk_filename, filename, metadata) = self
1924            .build_distribution(
1925                source,
1926                source_entry.path(),
1927                None,
1928                &cache_shard,
1929                NoSources::None,
1930            )
1931            .await?;
1932
1933        if let Some(task) = task {
1934            if let Some(reporter) = self.reporter.as_ref() {
1935                reporter.on_build_complete(source, task);
1936            }
1937        }
1938
1939        // Store the metadata.
1940        let metadata_entry = cache_shard.entry(METADATA);
1941        write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?)
1942            .await
1943            .map_err(Error::CacheWrite)?;
1944
1945        Ok(BuiltWheelMetadata {
1946            path: cache_shard.join(&disk_filename).into_boxed_path(),
1947            target: cache_shard.join(filename.stem()).into_boxed_path(),
1948            filename,
1949            hashes: revision.into_hashes(),
1950            cache_info: CacheInfo::default(),
1951            build_info,
1952        })
1953    }
1954
1955    /// Build a source distribution from a Git repository.
1956    async fn git_archive_metadata(
1957        &self,
1958        source: &BuildableSource<'_>,
1959        resource: &GitPathSourceUrl<'_>,
1960        hashes: HashPolicy<'_>,
1961        client: &ManagedClient<'_>,
1962    ) -> Result<ArchiveMetadata, Error> {
1963        // Fetch the Git repository.
1964        let fetch = self
1965            .build_context
1966            .git()
1967            .fetch(
1968                resource.git,
1969                client.unmanaged.git_http_settings(resource.git.url()),
1970                self.build_context.cache().bucket(CacheBucket::Git),
1971                self.reporter
1972                    .clone()
1973                    .map(|reporter| reporter.into_git_reporter()),
1974            )
1975            .await?;
1976
1977        let git_sha = fetch.git().precise().expect("Exact commit after checkout");
1978        let cache_shard = self.build_context.cache().shard(
1979            CacheBucket::SourceDistributions,
1980            WheelCache::Git(resource.url, git_sha.as_short_str()).root(),
1981        );
1982
1983        // Fetch the revision for the source distribution.
1984        let revision = self
1985            .git_archive_revision(source, resource, &fetch, &cache_shard, hashes)
1986            .await?;
1987
1988        // Before running the build, check that the hashes match.
1989        if !revision.satisfies(hashes) {
1990            return Err(Error::hash_mismatch(
1991                source.to_string(),
1992                hashes.digests(),
1993                revision.hashes(),
1994            ));
1995        }
1996
1997        let source_entry = cache_shard.entry(SOURCE);
1998
1999        // If the metadata is static, return it.
2000        let dynamic = match StaticMetadata::read(source, source_entry.path(), None).await? {
2001            StaticMetadata::Some(metadata) => {
2002                return Ok(ArchiveMetadata {
2003                    metadata: Metadata::from_metadata23(metadata),
2004                    hashes: revision.into_hashes(),
2005                });
2006            }
2007            StaticMetadata::Dynamic => true,
2008            StaticMetadata::None => false,
2009        };
2010
2011        // If the cache contains compatible metadata, return it.
2012        let metadata_entry = cache_shard.entry(METADATA);
2013        match CachedMetadata::read(&metadata_entry).await {
2014            Ok(Some(metadata)) => {
2015                if metadata.matches(source.name(), source.version()) {
2016                    debug!("Using cached metadata for: {source}");
2017                    return Ok(ArchiveMetadata {
2018                        metadata: Metadata::from_metadata23(metadata.into()),
2019                        hashes: revision.into_hashes(),
2020                    });
2021                }
2022                debug!("Cached metadata does not match expected name and version for: {source}");
2023            }
2024            Ok(None) => {}
2025            Err(err) => {
2026                debug!("Failed to deserialize cached metadata for: {source} ({err})");
2027            }
2028        }
2029
2030        // If the backend supports `prepare_metadata_for_build_wheel`, use it.
2031        if let Some(metadata) = self
2032            .build_metadata(source, source_entry.path(), None, NoSources::None)
2033            .boxed_local()
2034            .await?
2035        {
2036            // If necessary, mark the metadata as dynamic.
2037            let metadata = if dynamic {
2038                ResolutionMetadata {
2039                    dynamic: true,
2040                    ..metadata
2041                }
2042            } else {
2043                metadata
2044            };
2045
2046            // Store the metadata.
2047            fs::create_dir_all(metadata_entry.dir())
2048                .await
2049                .map_err(Error::CacheWrite)?;
2050            write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?)
2051                .await
2052                .map_err(Error::CacheWrite)?;
2053
2054            return Ok(ArchiveMetadata {
2055                metadata: Metadata::from_metadata23(metadata),
2056                hashes: revision.into_hashes(),
2057            });
2058        }
2059
2060        // If there are build settings or extra build dependencies, we need to scope to a cache shard.
2061        let config_settings = self.config_settings_for(source.name());
2062        let extra_build_deps = self.extra_build_dependencies_for(source.name());
2063        let extra_build_variables = self.extra_build_variables_for(source.name());
2064        let build_info = BuildInfo::from_settings(
2065            config_settings.into_owned(),
2066            extra_build_deps.to_vec(),
2067            extra_build_variables.cloned(),
2068        );
2069        let cache_shard = build_info
2070            .cache_shard()
2071            .map(|digest| cache_shard.shard(digest))
2072            .unwrap_or(cache_shard);
2073
2074        // Otherwise, we need to build a wheel.
2075        let task = self
2076            .reporter
2077            .as_ref()
2078            .map(|reporter| reporter.on_build_start(source));
2079
2080        let (_disk_filename, _filename, metadata) = self
2081            .build_distribution(
2082                source,
2083                source_entry.path(),
2084                None,
2085                &cache_shard,
2086                NoSources::None,
2087            )
2088            .await?;
2089
2090        if let Some(task) = task {
2091            if let Some(reporter) = self.reporter.as_ref() {
2092                reporter.on_build_complete(source, task);
2093            }
2094        }
2095
2096        // If necessary, mark the metadata as dynamic.
2097        let metadata = if dynamic {
2098            ResolutionMetadata {
2099                dynamic: true,
2100                ..metadata
2101            }
2102        } else {
2103            metadata
2104        };
2105
2106        // Store the metadata.
2107        write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?)
2108            .await
2109            .map_err(Error::CacheWrite)?;
2110
2111        Ok(ArchiveMetadata {
2112            metadata: Metadata::from_metadata23(metadata),
2113            hashes: revision.into_hashes(),
2114        })
2115    }
2116
2117    /// Build a source distribution from a Git repository.
2118    async fn git_source_tree(
2119        &self,
2120        source: &BuildableSource<'_>,
2121        resource: &GitDirectorySourceUrl<'_>,
2122        tags: &Tags,
2123        hashes: HashPolicy<'_>,
2124        client: &ManagedClient<'_>,
2125    ) -> Result<BuiltWheelMetadata, Error> {
2126        // Before running the build, check that the hashes match.
2127        if hashes.requires_validation() {
2128            return Err(Error::HashesNotSupportedGit(source.to_string()));
2129        }
2130
2131        let fetch = fetch_git_source_tree(
2132            self.build_context.git(),
2133            resource.git,
2134            resource.url.to_url(),
2135            resource.subdirectory,
2136            client.unmanaged.git_http_settings(resource.git.url()),
2137            self.build_context.cache(),
2138            self.reporter
2139                .clone()
2140                .map(|reporter| reporter.into_git_reporter()),
2141        )
2142        .await?;
2143
2144        let git_sha = fetch.git().precise().expect("Exact commit after checkout");
2145        let cache_shard = self.build_context.cache().shard(
2146            CacheBucket::SourceDistributions,
2147            WheelCache::Git(resource.url, git_sha.as_short_str()).root(),
2148        );
2149        let metadata_entry = cache_shard.entry(METADATA);
2150
2151        // Acquire the advisory lock.
2152        let _lock = cache_shard.lock().await.map_err(Error::CacheLock)?;
2153
2154        // We don't track any cache information for Git-based source distributions; they're assumed
2155        // to be immutable.
2156        let cache_info = CacheInfo::default();
2157
2158        // We don't compute hashes for Git-based source distributions, since the Git commit SHA is
2159        // used as the identifier.
2160        let hashes = HashDigests::empty();
2161
2162        // If there are build settings or extra build dependencies, we need to scope to a cache shard.
2163        let config_settings = self.config_settings_for(source.name());
2164        let extra_build_deps = self.extra_build_dependencies_for(source.name());
2165        let extra_build_variables = self.extra_build_variables_for(source.name());
2166        let build_info = BuildInfo::from_settings(
2167            config_settings.into_owned(),
2168            extra_build_deps.to_vec(),
2169            extra_build_variables.cloned(),
2170        );
2171        let cache_shard = build_info
2172            .cache_shard()
2173            .map(|digest| cache_shard.shard(digest))
2174            .unwrap_or(cache_shard);
2175
2176        // If the cache contains a compatible wheel, return it.
2177        if let Some(file) = BuiltWheelFile::find_in_cache(tags, &cache_shard)
2178            .ok()
2179            .flatten()
2180            .filter(|file| file.matches(source.name(), source.version()))
2181        {
2182            return Ok(BuiltWheelMetadata::from_file(
2183                file, hashes, cache_info, build_info,
2184            ));
2185        }
2186
2187        let task = self
2188            .reporter
2189            .as_ref()
2190            .map(|reporter| reporter.on_build_start(source));
2191
2192        let (disk_filename, filename, metadata) = self
2193            .build_distribution(
2194                source,
2195                fetch.path(),
2196                resource.subdirectory,
2197                &cache_shard,
2198                self.build_context.sources().clone(),
2199            )
2200            .await?;
2201
2202        if let Some(task) = task {
2203            if let Some(reporter) = self.reporter.as_ref() {
2204                reporter.on_build_complete(source, task);
2205            }
2206        }
2207
2208        // Store the metadata.
2209        write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?)
2210            .await
2211            .map_err(Error::CacheWrite)?;
2212
2213        Ok(BuiltWheelMetadata {
2214            path: cache_shard.join(&disk_filename).into_boxed_path(),
2215            target: cache_shard.join(filename.stem()).into_boxed_path(),
2216            filename,
2217            hashes,
2218            cache_info,
2219            build_info,
2220        })
2221    }
2222
2223    /// Build the source distribution's metadata from a Git repository.
2224    ///
2225    /// If the build backend supports `prepare_metadata_for_build_wheel`, this method will avoid
2226    /// building the wheel.
2227    async fn git_source_tree_metadata(
2228        &self,
2229        source: &BuildableSource<'_>,
2230        resource: &GitDirectorySourceUrl<'_>,
2231        hashes: HashPolicy<'_>,
2232        client: &ManagedClient<'_>,
2233        credentials_cache: &CredentialsCache,
2234    ) -> Result<ArchiveMetadata, Error> {
2235        // Before running the build, check that the hashes match.
2236        if hashes.requires_validation() {
2237            return Err(Error::HashesNotSupportedGit(source.to_string()));
2238        }
2239
2240        // If the reference appears to be a commit, and we've already checked it out, avoid taking
2241        // the GitHub fast path.
2242        let cache_shard = resource
2243            .git
2244            .reference()
2245            .as_str()
2246            .and_then(|reference| GitOid::from_str(reference).ok())
2247            .map(|oid| {
2248                self.build_context.cache().shard(
2249                    CacheBucket::SourceDistributions,
2250                    WheelCache::Git(resource.url, oid.as_short_str()).root(),
2251                )
2252            });
2253        if cache_shard
2254            .as_ref()
2255            .is_some_and(|cache_shard| cache_shard.is_dir())
2256        {
2257            debug!("Skipping GitHub fast path for: {source} (shard exists)");
2258        } else {
2259            debug!("Attempting GitHub fast path for: {source}");
2260
2261            // If this is GitHub URL, attempt to resolve to a precise commit using the GitHub API.
2262            match self
2263                .build_context
2264                .git()
2265                .github_fast_path(
2266                    resource.git,
2267                    client
2268                        .unmanaged
2269                        .uncached_client(resource.git.url())
2270                        .raw_client(),
2271                )
2272                .await
2273            {
2274                Ok(Some(precise)) => {
2275                    // There's no need to check the cache, since we can't use cached metadata if there are
2276                    // sources, and we can't know if there are sources without fetching the
2277                    // `pyproject.toml`.
2278                    //
2279                    // For the same reason, there's no need to write to the cache, since we won't be able to
2280                    // use it on subsequent runs.
2281                    match self
2282                        .github_metadata(precise, source, resource, client)
2283                        .await
2284                    {
2285                        Ok(Some(metadata)) => {
2286                            // Validate the metadata, but ignore it if the metadata doesn't match.
2287                            match validate_metadata(source, &metadata) {
2288                                Ok(()) => {
2289                                    debug!(
2290                                        "Found static metadata via GitHub fast path for: {source}"
2291                                    );
2292                                    return Ok(ArchiveMetadata {
2293                                        metadata: Metadata::from_metadata23(metadata),
2294                                        hashes: HashDigests::empty(),
2295                                    });
2296                                }
2297                                Err(err) => {
2298                                    debug!(
2299                                        "Ignoring `pyproject.toml` from GitHub for {source}: {err}"
2300                                    );
2301                                }
2302                            }
2303                        }
2304                        Ok(None) => {
2305                            // Nothing to do.
2306                        }
2307                        Err(err) => {
2308                            debug!(
2309                                "Failed to fetch `pyproject.toml` via GitHub fast path for: {source} ({err})"
2310                            );
2311                        }
2312                    }
2313                }
2314                Ok(None) => {
2315                    // Nothing to do.
2316                }
2317                Err(err) => {
2318                    debug!("Failed to resolve commit via GitHub fast path for: {source} ({err})");
2319                }
2320            }
2321        }
2322
2323        let fetch = fetch_git_source_tree(
2324            self.build_context.git(),
2325            resource.git,
2326            resource.url.to_url(),
2327            resource.subdirectory,
2328            client.unmanaged.git_http_settings(resource.git.url()),
2329            self.build_context.cache(),
2330            self.reporter
2331                .clone()
2332                .map(|reporter| reporter.into_git_reporter()),
2333        )
2334        .await?;
2335
2336        let git_sha = fetch.git().precise().expect("Exact commit after checkout");
2337        let cache_shard = self.build_context.cache().shard(
2338            CacheBucket::SourceDistributions,
2339            WheelCache::Git(resource.url, git_sha.as_short_str()).root(),
2340        );
2341        let metadata_entry = cache_shard.entry(METADATA);
2342
2343        // Acquire the advisory lock.
2344        let _lock = cache_shard.lock().await.map_err(Error::CacheLock)?;
2345
2346        let path = if let Some(subdirectory) = resource.subdirectory {
2347            Cow::Owned(fetch.path().join(subdirectory))
2348        } else {
2349            Cow::Borrowed(fetch.path())
2350        };
2351
2352        let git_member = GitWorkspaceMember {
2353            fetch_root: fetch.path(),
2354            git_source: resource,
2355        };
2356
2357        // If the metadata is static, return it.
2358        let dynamic =
2359            match StaticMetadata::read(source, fetch.path(), resource.subdirectory).await? {
2360                StaticMetadata::Some(metadata) => {
2361                    return Ok(ArchiveMetadata::from(
2362                        Metadata::from_workspace(
2363                            metadata,
2364                            &path,
2365                            Some(&git_member),
2366                            self.build_context.locations(),
2367                            self.build_context.sources().clone(),
2368                            self.build_context
2369                                .source_tree_editable_policy()
2370                                .workspace_member_editable(None),
2371                            self.build_context.cache(),
2372                            self.build_context.workspace_cache(),
2373                            credentials_cache,
2374                        )
2375                        .await?,
2376                    ));
2377                }
2378                StaticMetadata::Dynamic => true,
2379                StaticMetadata::None => false,
2380            };
2381
2382        // If the cache contains compatible metadata, return it.
2383        if self
2384            .build_context
2385            .cache()
2386            .freshness(&metadata_entry, source.name(), source.source_tree())
2387            .map_err(Error::CacheRead)?
2388            .is_fresh()
2389        {
2390            match CachedMetadata::read(&metadata_entry).await {
2391                Ok(Some(metadata)) => {
2392                    if metadata.matches(source.name(), source.version()) {
2393                        debug!("Using cached metadata for: {source}");
2394
2395                        let git_member = GitWorkspaceMember {
2396                            fetch_root: fetch.path(),
2397                            git_source: resource,
2398                        };
2399                        return Ok(ArchiveMetadata::from(
2400                            Metadata::from_workspace(
2401                                metadata.into(),
2402                                &path,
2403                                Some(&git_member),
2404                                self.build_context.locations(),
2405                                self.build_context.sources().clone(),
2406                                self.build_context
2407                                    .source_tree_editable_policy()
2408                                    .workspace_member_editable(None),
2409                                self.build_context.cache(),
2410                                self.build_context.workspace_cache(),
2411                                credentials_cache,
2412                            )
2413                            .await?,
2414                        ));
2415                    }
2416                    debug!(
2417                        "Cached metadata does not match expected name and version for: {source}"
2418                    );
2419                }
2420                Ok(None) => {}
2421                Err(err) => {
2422                    debug!("Failed to deserialize cached metadata for: {source} ({err})");
2423                }
2424            }
2425        }
2426
2427        // If the backend supports `prepare_metadata_for_build_wheel`, use it.
2428        if let Some(metadata) = self
2429            .build_metadata(
2430                source,
2431                fetch.path(),
2432                resource.subdirectory,
2433                self.build_context.sources().clone(),
2434            )
2435            .boxed_local()
2436            .await?
2437        {
2438            // If necessary, mark the metadata as dynamic.
2439            let metadata = if dynamic {
2440                ResolutionMetadata {
2441                    dynamic: true,
2442                    ..metadata
2443                }
2444            } else {
2445                metadata
2446            };
2447
2448            // Store the metadata.
2449            fs::create_dir_all(metadata_entry.dir())
2450                .await
2451                .map_err(Error::CacheWrite)?;
2452            write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?)
2453                .await
2454                .map_err(Error::CacheWrite)?;
2455
2456            return Ok(ArchiveMetadata::from(
2457                Metadata::from_workspace(
2458                    metadata,
2459                    &path,
2460                    Some(&git_member),
2461                    self.build_context.locations(),
2462                    self.build_context.sources().clone(),
2463                    self.build_context
2464                        .source_tree_editable_policy()
2465                        .workspace_member_editable(None),
2466                    self.build_context.cache(),
2467                    self.build_context.workspace_cache(),
2468                    credentials_cache,
2469                )
2470                .await?,
2471            ));
2472        }
2473
2474        // If there are build settings or extra build dependencies, we need to scope to a cache shard.
2475        let config_settings = self.config_settings_for(source.name());
2476        let extra_build_deps = self.extra_build_dependencies_for(source.name());
2477        let extra_build_variables = self.extra_build_variables_for(source.name());
2478        let build_info = BuildInfo::from_settings(
2479            config_settings.into_owned(),
2480            extra_build_deps.to_vec(),
2481            extra_build_variables.cloned(),
2482        );
2483        let cache_shard = build_info
2484            .cache_shard()
2485            .map(|digest| cache_shard.shard(digest))
2486            .unwrap_or(cache_shard);
2487
2488        // Otherwise, we need to build a wheel.
2489        let task = self
2490            .reporter
2491            .as_ref()
2492            .map(|reporter| reporter.on_build_start(source));
2493
2494        let (_disk_filename, _filename, metadata) = self
2495            .build_distribution(
2496                source,
2497                fetch.path(),
2498                resource.subdirectory,
2499                &cache_shard,
2500                self.build_context.sources().clone(),
2501            )
2502            .await?;
2503
2504        if let Some(task) = task {
2505            if let Some(reporter) = self.reporter.as_ref() {
2506                reporter.on_build_complete(source, task);
2507            }
2508        }
2509
2510        // If necessary, mark the metadata as dynamic.
2511        let metadata = if dynamic {
2512            ResolutionMetadata {
2513                dynamic: true,
2514                ..metadata
2515            }
2516        } else {
2517            metadata
2518        };
2519
2520        // Store the metadata.
2521        write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?)
2522            .await
2523            .map_err(Error::CacheWrite)?;
2524
2525        Ok(ArchiveMetadata::from(
2526            Metadata::from_workspace(
2527                metadata,
2528                fetch.path(),
2529                Some(&git_member),
2530                self.build_context.locations(),
2531                self.build_context.sources().clone(),
2532                self.build_context
2533                    .source_tree_editable_policy()
2534                    .workspace_member_editable(None),
2535                self.build_context.cache(),
2536                self.build_context.workspace_cache(),
2537                credentials_cache,
2538            )
2539            .await?,
2540        ))
2541    }
2542
2543    /// Resolve a source to a specific revision.
2544    pub(crate) async fn resolve_revision(
2545        &self,
2546        source: &BuildableSource<'_>,
2547        client: &ManagedClient<'_>,
2548    ) -> Result<Option<GitOid>, Error> {
2549        let git = match source {
2550            BuildableSource::Dist(SourceDist::GitDirectory(source)) => &*source.git,
2551            BuildableSource::Dist(SourceDist::GitPath(source)) => &*source.git,
2552            BuildableSource::Url(SourceUrl::GitDirectory(source)) => source.git,
2553            BuildableSource::Url(SourceUrl::GitPath(source)) => source.git,
2554            _ => {
2555                return Ok(None);
2556            }
2557        };
2558
2559        // If the URL is already precise, return it.
2560        if let Some(precise) = self.build_context.git().get_precise(git) {
2561            debug!("Precise commit already known: {source}");
2562            return Ok(Some(precise));
2563        }
2564
2565        // If this is GitHub URL, attempt to resolve to a precise commit using the GitHub API.
2566        if let Some(precise) = self
2567            .build_context
2568            .git()
2569            .github_fast_path(
2570                git,
2571                client.unmanaged.uncached_client(git.url()).raw_client(),
2572            )
2573            .await?
2574        {
2575            debug!("Resolved to precise commit via GitHub fast path: {source}");
2576            return Ok(Some(precise));
2577        }
2578
2579        // Otherwise, fetch the Git repository.
2580        let fetch = self
2581            .build_context
2582            .git()
2583            .fetch(
2584                git,
2585                client.unmanaged.git_http_settings(git.url()),
2586                self.build_context.cache().bucket(CacheBucket::Git),
2587                self.reporter
2588                    .clone()
2589                    .map(|reporter| reporter.into_git_reporter()),
2590            )
2591            .await?;
2592
2593        Ok(fetch.git().precise())
2594    }
2595
2596    /// Fetch static [`ResolutionMetadata`] from a GitHub repository, if possible.
2597    ///
2598    /// Attempts to fetch the `pyproject.toml` from the resolved commit using the GitHub API.
2599    async fn github_metadata(
2600        &self,
2601        commit: GitOid,
2602        source: &BuildableSource<'_>,
2603        resource: &GitDirectorySourceUrl<'_>,
2604        client: &ManagedClient<'_>,
2605    ) -> Result<Option<ResolutionMetadata>, Error> {
2606        let GitDirectorySourceUrl {
2607            git, subdirectory, ..
2608        } = resource;
2609
2610        // The fast path isn't available for subdirectories. If a `pyproject.toml` is in a
2611        // subdirectory, it could be part of a workspace; and if it's part of a workspace, it could
2612        // have `tool.uv.sources` entries that it inherits from the workspace root.
2613        if subdirectory.is_some() {
2614            return Ok(None);
2615        }
2616
2617        let Some(GitHubRepository { owner, repo }) = GitHubRepository::parse(git.repository())
2618        else {
2619            return Ok(None);
2620        };
2621
2622        // Fetch the `pyproject.toml` from the resolved commit.
2623        let url =
2624            format!("https://raw.githubusercontent.com/{owner}/{repo}/{commit}/pyproject.toml");
2625
2626        debug!("Attempting to fetch `pyproject.toml` from: {url}");
2627
2628        let content = client
2629            .managed(async |client| {
2630                let response = client.uncached_client(git.url()).get(&url).send().await?;
2631
2632                // If the `pyproject.toml` does not exist, the GitHub API will return a 404.
2633                if response.status() == StatusCode::NOT_FOUND {
2634                    return Ok::<Option<String>, Error>(None);
2635                }
2636                response.error_for_status_ref()?;
2637
2638                let content = response.text().await?;
2639                Ok::<Option<String>, Error>(Some(content))
2640            })
2641            .await?;
2642
2643        let Some(content) = content else {
2644            debug!("GitHub API returned a 404 for: {url}");
2645            return Ok(None);
2646        };
2647
2648        // Parse the `pyproject.toml`.
2649        let pyproject_toml = match PyProjectToml::from_toml(&content, source) {
2650            Ok(metadata) => metadata,
2651            Err(
2652                uv_pypi_types::MetadataError::InvalidPyprojectTomlSyntax(..)
2653                | uv_pypi_types::MetadataError::InvalidPyprojectTomlSchema(..),
2654            ) => {
2655                debug!("Failed to read `pyproject.toml` from GitHub API for: {url}");
2656                return Ok(None);
2657            }
2658            Err(err) => return Err(err.into()),
2659        };
2660
2661        // Parse the metadata.
2662        let metadata =
2663            match ResolutionMetadata::parse_pyproject_toml(pyproject_toml, source.version()) {
2664                Ok(metadata) => metadata,
2665                Err(
2666                    uv_pypi_types::MetadataError::Pep508Error(..)
2667                    | uv_pypi_types::MetadataError::DynamicField(..)
2668                    | uv_pypi_types::MetadataError::FieldNotFound(..)
2669                    | uv_pypi_types::MetadataError::PoetrySyntax,
2670                ) => {
2671                    debug!("Failed to extract static metadata from GitHub API for: {url}");
2672                    return Ok(None);
2673                }
2674                Err(err) => return Err(err.into()),
2675            };
2676
2677        // Determine whether the project has `tool.uv.sources`. If the project has sources, it must
2678        // be lowered, which requires access to the workspace. For example, it could have workspace
2679        // members that need to be translated to concrete paths on disk.
2680        //
2681        // TODO(charlie): We could still use the `pyproject.toml` if the sources are all `git` or
2682        // `url` sources; this is only applicable to `workspace` and `path` sources. It's awkward,
2683        // though, because we'd need to pass a path into the lowering routine, and that path would
2684        // be incorrect (we'd just be relying on it not being used).
2685        match has_sources(&content) {
2686            Ok(false) => {}
2687            Ok(true) => {
2688                debug!("Skipping GitHub fast path; `pyproject.toml` has sources: {url}");
2689                return Ok(None);
2690            }
2691            Err(err) => {
2692                debug!("Failed to parse `tool.uv.sources` from GitHub API for: {url} ({err})");
2693                return Ok(None);
2694            }
2695        }
2696
2697        Ok(Some(metadata))
2698    }
2699
2700    /// Heal a [`Revision`] for a local archive.
2701    async fn heal_archive_revision(
2702        &self,
2703        source: &BuildableSource<'_>,
2704        resource: &PathSourceUrl<'_>,
2705        entry: &CacheEntry,
2706        revision: Revision,
2707        hashes: HashPolicy<'_>,
2708    ) -> Result<Revision, Error> {
2709        warn!("Re-extracting missing source distribution: {source}");
2710
2711        // Take the union of the requested and existing hash algorithms.
2712        let algorithms = {
2713            let mut algorithms = hashes.algorithms();
2714            for digest in revision.hashes() {
2715                algorithms.push(digest.algorithm());
2716            }
2717            algorithms.sort();
2718            algorithms.dedup();
2719            algorithms
2720        };
2721
2722        let hashes = self
2723            .persist_archive(&resource.path, resource.ext, entry.path(), &algorithms)
2724            .await?;
2725        for existing in revision.hashes() {
2726            if !hashes.contains(existing) {
2727                return Err(Error::CacheHeal(source.to_string(), existing.algorithm()));
2728            }
2729        }
2730        Ok(revision.with_hashes(HashDigests::from(hashes)))
2731    }
2732
2733    /// Heal a [`Revision`] for a remote archive.
2734    async fn heal_url_revision(
2735        &self,
2736        source: &BuildableSource<'_>,
2737        ext: SourceDistExtension,
2738        url: &DisplaySafeUrl,
2739        index: Option<&IndexUrl>,
2740        entry: &CacheEntry,
2741        revision: Revision,
2742        hashes: HashPolicy<'_>,
2743        client: &ManagedClient<'_>,
2744    ) -> Result<Revision, Error> {
2745        warn!("Re-downloading missing source distribution: {source}");
2746        let cache_entry = entry.shard().entry(HTTP_REVISION);
2747
2748        // Determine the cache control policy for the request.
2749        let cache_control = match client.unmanaged.connectivity() {
2750            Connectivity::Online
2751                if let Some(header) = index.and_then(|index| {
2752                    self.build_context
2753                        .locations()
2754                        .artifact_cache_control_for(index)
2755                }) =>
2756            {
2757                CacheControl::Override(header)
2758            }
2759            Connectivity::Online => CacheControl::from(
2760                self.build_context
2761                    .cache()
2762                    .freshness(&cache_entry, source.name(), source.source_tree())
2763                    .map_err(Error::CacheRead)?,
2764            ),
2765            Connectivity::Offline => CacheControl::AllowStale,
2766        };
2767
2768        let download = |response| {
2769            async {
2770                // Take the union of the requested and existing hash algorithms.
2771                let algorithms = {
2772                    let mut algorithms = http_hash_algorithms(hashes);
2773                    for digest in revision.hashes() {
2774                        algorithms.push(digest.algorithm());
2775                    }
2776                    algorithms.sort();
2777                    algorithms.dedup();
2778                    algorithms
2779                };
2780
2781                let (hashes, size) = self
2782                    .download_archive(response, source, ext, entry.path(), &algorithms)
2783                    .await?;
2784                for existing in revision.hashes() {
2785                    if !hashes.contains(existing) {
2786                        return Err(Error::CacheHeal(source.to_string(), existing.algorithm()));
2787                    }
2788                }
2789                Ok(revision
2790                    .clone()
2791                    .with_hashes(HashDigests::from(hashes))
2792                    .with_size(size))
2793            }
2794            .boxed_local()
2795            .instrument(info_span!("download", source_dist = %source))
2796        };
2797        client
2798            .managed(async |client| {
2799                client
2800                    .cached_client()
2801                    .skip_cache_with_retry(
2802                        Self::request(url.clone(), client)?,
2803                        &cache_entry,
2804                        cache_control.clone(),
2805                        download,
2806                    )
2807                    .await
2808                    .map_err(|err| match err {
2809                        CachedClientError::Callback { err, .. } => err,
2810                        CachedClientError::Client(err) => Error::Client(err),
2811                    })
2812            })
2813            .await
2814    }
2815
2816    /// Download and unzip a source distribution into the cache from an HTTP response.
2817    async fn download_archive(
2818        &self,
2819        response: Response,
2820        source: &BuildableSource<'_>,
2821        ext: SourceDistExtension,
2822        target: &Path,
2823        algorithms: &[HashAlgorithm],
2824    ) -> Result<(Vec<HashDigest>, u64), Error> {
2825        let temp_dir = tempfile::tempdir_in(
2826            self.build_context
2827                .cache()
2828                .bucket(CacheBucket::SourceDistributions),
2829        )
2830        .map_err(Error::CacheWrite)?;
2831
2832        let reader = response
2833            .bytes_stream()
2834            .map_err(std::io::Error::other)
2835            .into_async_read();
2836
2837        // Create a hasher for each hash algorithm.
2838        let mut hashers = algorithms
2839            .iter()
2840            .copied()
2841            .map(Hasher::from)
2842            .collect::<Vec<_>>();
2843        let mut hasher = uv_extract::hash::HashReader::new(reader.compat(), &mut hashers);
2844
2845        // Download and unzip the source distribution into a temporary directory.
2846        let span = info_span!("download_source_dist", source_dist = %source);
2847        uv_extract::stream::archive(&mut hasher, ext, temp_dir.path())
2848            .await
2849            .map_err(|err| Error::Extract(source.to_string(), err))?;
2850        drop(span);
2851
2852        let expected_size = match source {
2853            BuildableSource::Dist(SourceDist::Registry(dist)) if dist.size_is_authoritative => {
2854                dist.size()
2855            }
2856            BuildableSource::Dist(SourceDist::DirectUrl(dist)) => dist.size(),
2857            _ => None,
2858        };
2859
2860        // If necessary, exhaust the reader to compute the hash or validate the archive size.
2861        if !algorithms.is_empty() || expected_size.is_some() {
2862            hasher.finish().await.map_err(Error::HashExhaustion)?;
2863        }
2864        if let Some(expected) = expected_size
2865            && hasher.bytes_read() != expected
2866        {
2867            return Err(Error::MismatchedSize {
2868                distribution: source.to_string(),
2869                expected,
2870                actual: hasher.bytes_read(),
2871            });
2872        }
2873
2874        let size = hasher.bytes_read();
2875        let hashes = hashers.into_iter().map(HashDigest::from).collect();
2876
2877        // Extract the top-level directory.
2878        let extracted = match uv_extract::strip_component(temp_dir.path()) {
2879            Ok(top_level) => top_level,
2880            Err(uv_extract::Error::NonSingularArchive(_)) => temp_dir.keep(),
2881            Err(err) => {
2882                return Err(Error::Extract(
2883                    temp_dir.path().to_string_lossy().into_owned(),
2884                    err,
2885                ));
2886            }
2887        };
2888
2889        // Persist it to the cache.
2890        fs_err::tokio::create_dir_all(target.parent().expect("Cache entry to have parent"))
2891            .await
2892            .map_err(Error::CacheWrite)?;
2893        if let Err(err) = rename_with_retry(extracted, target).await {
2894            // If the directory already exists, accept it.
2895            if err.kind() == std::io::ErrorKind::AlreadyExists {
2896                warn!("Directory already exists: {}", target.display());
2897            } else {
2898                return Err(Error::CacheWrite(err));
2899            }
2900        }
2901
2902        Ok((hashes, size))
2903    }
2904
2905    /// Extract a local archive, and store it at the given [`CacheEntry`].
2906    async fn persist_archive(
2907        &self,
2908        path: &Path,
2909        ext: SourceDistExtension,
2910        target: &Path,
2911        algorithms: &[HashAlgorithm],
2912    ) -> Result<Vec<HashDigest>, Error> {
2913        debug!("Unpacking for build: {}", path.display());
2914
2915        let temp_dir = tempfile::tempdir_in(
2916            self.build_context
2917                .cache()
2918                .bucket(CacheBucket::SourceDistributions),
2919        )
2920        .map_err(Error::CacheWrite)?;
2921        let reader = fs_err::tokio::File::open(&path)
2922            .await
2923            .map_err(Error::CacheRead)?;
2924
2925        // Create a hasher for each hash algorithm.
2926        let mut hashers = algorithms
2927            .iter()
2928            .copied()
2929            .map(Hasher::from)
2930            .collect::<Vec<_>>();
2931        let mut hasher = uv_extract::hash::HashReader::new(reader, &mut hashers);
2932
2933        // Unzip the archive into a temporary directory.
2934        uv_extract::stream::archive(&mut hasher, ext, &temp_dir.path())
2935            .await
2936            .map_err(|err| Error::Extract(temp_dir.path().to_string_lossy().into_owned(), err))?;
2937
2938        // If necessary, exhaust the reader to compute the hash.
2939        if !algorithms.is_empty() {
2940            hasher.finish().await.map_err(Error::HashExhaustion)?;
2941        }
2942
2943        let hashes = hashers.into_iter().map(HashDigest::from).collect();
2944
2945        // Extract the top-level directory from the archive.
2946        let extracted = match uv_extract::strip_component(temp_dir.path()) {
2947            Ok(top_level) => top_level,
2948            Err(uv_extract::Error::NonSingularArchive(_)) => temp_dir.path().to_path_buf(),
2949            Err(err) => {
2950                return Err(Error::Extract(
2951                    temp_dir.path().to_string_lossy().into_owned(),
2952                    err,
2953                ));
2954            }
2955        };
2956
2957        // Persist it to the cache.
2958        fs_err::tokio::create_dir_all(target.parent().expect("Cache entry to have parent"))
2959            .await
2960            .map_err(Error::CacheWrite)?;
2961        if let Err(err) = rename_with_retry(extracted, target).await {
2962            // If the directory already exists, accept it.
2963            if err.kind() == std::io::ErrorKind::DirectoryNotEmpty {
2964                warn!("Directory already exists: {}", target.display());
2965            } else {
2966                return Err(Error::CacheWrite(err));
2967            }
2968        }
2969
2970        Ok(hashes)
2971    }
2972
2973    /// For Git directories, we check them out into the cache, so we need to avoid workspace
2974    /// discovery that goes outside the cache.
2975    fn stop_discovery_at<'path>(
2976        source: &BuildableSource<'_>,
2977        source_root: &'path Path,
2978    ) -> Option<&'path Path> {
2979        if matches!(
2980            source,
2981            BuildableSource::Dist(SourceDist::GitDirectory(_))
2982                | BuildableSource::Url(SourceUrl::GitDirectory(_))
2983        ) {
2984            Some(source_root)
2985        } else {
2986            None
2987        }
2988    }
2989
2990    /// Build a source distribution, storing the built wheel in the cache.
2991    ///
2992    /// Returns the un-normalized disk filename, the parsed, normalized filename and the metadata
2993    #[instrument(skip_all, fields(dist = %source))]
2994    async fn build_distribution(
2995        &self,
2996        source: &BuildableSource<'_>,
2997        source_root: &Path,
2998        subdirectory: Option<&Path>,
2999        cache_shard: &CacheShard,
3000        no_sources: NoSources,
3001    ) -> Result<(String, WheelFilename, ResolutionMetadata), Error> {
3002        debug!("Building: {source}");
3003
3004        // Guard against build of source distributions when disabled.
3005        if self
3006            .build_context
3007            .build_options()
3008            .no_build_requirement(source.name())
3009        {
3010            if source.is_editable() {
3011                debug!("Allowing build for editable source distribution: {source}");
3012            } else {
3013                return Err(Error::NoBuild);
3014            }
3015        }
3016
3017        // Build into a temporary directory, to prevent partial builds.
3018        let temp_dir = self
3019            .build_context
3020            .cache()
3021            .build_dir()
3022            .map_err(Error::CacheWrite)?;
3023
3024        // Build the wheel.
3025        fs::create_dir_all(&cache_shard)
3026            .await
3027            .map_err(Error::CacheWrite)?;
3028
3029        // Try a direct build if that isn't disabled and the uv build backend is used.
3030        let disk_filename = if let Some(name) = self
3031            .build_context
3032            .direct_build(
3033                source_root,
3034                subdirectory,
3035                temp_dir.path(),
3036                no_sources.clone(),
3037                if source.is_editable() {
3038                    BuildKind::Editable
3039                } else {
3040                    BuildKind::Wheel
3041                },
3042                Some(&source.to_string()),
3043            )
3044            .await
3045            .map_err(|err| Error::Build(err.into()))?
3046        {
3047            // In the uv build backend, the normalized filename and the disk filename are the same.
3048            name.to_string()
3049        } else {
3050            // Identify the base Python interpreter to use in the cache key.
3051            let base_python = if cfg!(unix) {
3052                self.build_context
3053                    .interpreter()
3054                    .await
3055                    .find_base_python()
3056                    .map_err(Error::BaseInterpreter)?
3057            } else {
3058                self.build_context
3059                    .interpreter()
3060                    .await
3061                    .to_base_python()
3062                    .map_err(Error::BaseInterpreter)?
3063            };
3064
3065            let build_kind = if source.is_editable() {
3066                BuildKind::Editable
3067            } else {
3068                BuildKind::Wheel
3069            };
3070
3071            let install_path = if let Some(subdirectory) = subdirectory {
3072                source_root.join(subdirectory)
3073            } else {
3074                source_root.to_path_buf()
3075            };
3076
3077            let stop_discovery_at = Self::stop_discovery_at(source, source_root);
3078
3079            let build_key = BuildKey {
3080                base_python: base_python.into_boxed_path(),
3081                source_root: source_root.to_path_buf().into_boxed_path(),
3082                subdirectory: subdirectory
3083                    .map(|subdirectory| subdirectory.to_path_buf().into_boxed_path()),
3084                no_sources: no_sources.clone(),
3085                build_kind,
3086            };
3087
3088            if let Some(builder) = self.build_context.build_arena().remove(&build_key) {
3089                debug!("Reusing existing build environment for: {source}");
3090                let wheel = builder.wheel(temp_dir.path()).await.map_err(Error::Build)?;
3091
3092                // Store the build context.
3093                self.build_context.build_arena().insert(build_key, builder);
3094
3095                wheel
3096            } else {
3097                debug!("Creating build environment for: {source}");
3098
3099                let builder = self
3100                    .build_context
3101                    .setup_build(
3102                        source_root,
3103                        subdirectory,
3104                        &install_path,
3105                        stop_discovery_at,
3106                        Some(&source.to_string()),
3107                        source.as_dist(),
3108                        &no_sources,
3109                        if source.is_editable() {
3110                            BuildKind::Editable
3111                        } else {
3112                            BuildKind::Wheel
3113                        },
3114                        if uv_flags::contains(uv_flags::EnvironmentFlags::HIDE_BUILD_OUTPUT) {
3115                            BuildOutput::Quiet
3116                        } else {
3117                            BuildOutput::Debug
3118                        },
3119                        self.build_stack.cloned().unwrap_or_default(),
3120                    )
3121                    .await
3122                    .map_err(|err| Error::Build(err.into()))?;
3123
3124                // Build the wheel.
3125                let wheel = builder.wheel(temp_dir.path()).await.map_err(Error::Build)?;
3126
3127                // Store the build context.
3128                self.build_context.build_arena().insert(build_key, builder);
3129
3130                wheel
3131            }
3132        };
3133
3134        // Read the metadata from the wheel.
3135        let filename = WheelFilename::from_str(&disk_filename)?;
3136        let metadata = read_wheel_metadata(&filename, &temp_dir.path().join(&disk_filename))?;
3137
3138        // Validate the metadata.
3139        validate_metadata(source, &metadata)?;
3140        validate_filename(&filename, &metadata)?;
3141
3142        // Move the wheel to the cache.
3143        rename_with_retry(
3144            temp_dir.path().join(&disk_filename),
3145            cache_shard.join(&disk_filename),
3146        )
3147        .await
3148        .map_err(Error::CacheWrite)?;
3149
3150        debug!("Built `{source}` into `{disk_filename}`");
3151        Ok((disk_filename, filename, metadata))
3152    }
3153
3154    /// Build the metadata for a source distribution.
3155    #[instrument(skip_all, fields(dist = %source))]
3156    async fn build_metadata(
3157        &self,
3158        source: &BuildableSource<'_>,
3159        source_root: &Path,
3160        subdirectory: Option<&Path>,
3161        no_sources: NoSources,
3162    ) -> Result<Option<ResolutionMetadata>, Error> {
3163        debug!("Preparing metadata for: {source}");
3164
3165        let source_name = source.name();
3166        if self
3167            .build_context
3168            .build_options()
3169            .no_build_requirement(source_name)
3170            // Editable requirements without a known name need metadata to apply
3171            // package-specific build settings; named editables must respect `--no-build`.
3172            && !(source_name.is_none() && source.is_editable())
3173        {
3174            return if let Some(name) = source_name {
3175                Err(Error::NoBuildPackage(name.clone()))
3176            } else {
3177                Err(Error::NoBuild)
3178            };
3179        }
3180
3181        // Ensure that the _installed_ Python version is compatible with the `requires-python`
3182        // specifier.
3183        if let Some(requires_python) = source.requires_python() {
3184            let installed = self.build_context.interpreter().await.python_version();
3185            let target = release_specifiers_to_ranges(requires_python.clone())
3186                .bounding_range()
3187                .map(|bounding_range| bounding_range.0.cloned())
3188                .unwrap_or(Bound::Unbounded);
3189            let is_compatible = match target {
3190                Bound::Included(target) => *installed >= target,
3191                Bound::Excluded(target) => *installed > target,
3192                Bound::Unbounded => true,
3193            };
3194            if !is_compatible {
3195                return Err(Error::RequiresPython(
3196                    requires_python.clone(),
3197                    installed.clone(),
3198                ));
3199            }
3200        }
3201
3202        // Identify the base Python interpreter to use in the cache key.
3203        let base_python = if cfg!(unix) {
3204            self.build_context
3205                .interpreter()
3206                .await
3207                .find_base_python()
3208                .map_err(Error::BaseInterpreter)?
3209        } else {
3210            self.build_context
3211                .interpreter()
3212                .await
3213                .to_base_python()
3214                .map_err(Error::BaseInterpreter)?
3215        };
3216
3217        // Determine whether this is an editable or non-editable build.
3218        let build_kind = if source.is_editable() {
3219            BuildKind::Editable
3220        } else {
3221            BuildKind::Wheel
3222        };
3223
3224        let install_path = if let Some(subdirectory) = subdirectory {
3225            source_root.join(subdirectory)
3226        } else {
3227            source_root.to_path_buf()
3228        };
3229
3230        let stop_discovery_at = Self::stop_discovery_at(source, source_root);
3231
3232        // Set up the builder.
3233        let mut builder = self
3234            .build_context
3235            .setup_build(
3236                source_root,
3237                subdirectory,
3238                &install_path,
3239                stop_discovery_at,
3240                Some(&source.to_string()),
3241                source.as_dist(),
3242                &no_sources,
3243                build_kind,
3244                if uv_flags::contains(uv_flags::EnvironmentFlags::HIDE_BUILD_OUTPUT) {
3245                    BuildOutput::Quiet
3246                } else {
3247                    BuildOutput::Debug
3248                },
3249                self.build_stack.cloned().unwrap_or_default(),
3250            )
3251            .await
3252            .map_err(|err| Error::Build(err.into()))?;
3253
3254        // Build the metadata.
3255        let dist_info = builder.metadata().await.map_err(Error::Build)?;
3256
3257        // Store the build context.
3258        self.build_context.build_arena().insert(
3259            BuildKey {
3260                base_python: base_python.into_boxed_path(),
3261                source_root: source_root.to_path_buf().into_boxed_path(),
3262                subdirectory: subdirectory
3263                    .map(|subdirectory| subdirectory.to_path_buf().into_boxed_path()),
3264                no_sources,
3265                build_kind,
3266            },
3267            builder,
3268        );
3269
3270        // Return the `.dist-info` directory, if it exists.
3271        let Some(dist_info) = dist_info else {
3272            return Ok(None);
3273        };
3274
3275        // Read the metadata from disk.
3276        debug!("Prepared metadata for: {source}");
3277        let content = fs::read(dist_info.join("METADATA"))
3278            .await
3279            .map_err(Error::CacheRead)?;
3280        let metadata = ResolutionMetadata::parse_metadata(&content)?;
3281
3282        // Validate the metadata.
3283        validate_metadata(source, &metadata)?;
3284
3285        Ok(Some(metadata))
3286    }
3287
3288    /// Returns a GET [`reqwest::Request`] for the given URL.
3289    fn request(
3290        url: DisplaySafeUrl,
3291        client: &RegistryClient,
3292    ) -> Result<reqwest::Request, reqwest::Error> {
3293        client
3294            .uncached_client(&url)
3295            .get(Url::from(url))
3296            .header(
3297                // `reqwest` defaults to accepting compressed responses.
3298                // Specify identity encoding to get consistent .whl downloading
3299                // behavior from servers. ref: https://github.com/pypa/pip/pull/1688
3300                "accept-encoding",
3301                reqwest::header::HeaderValue::from_static("identity"),
3302            )
3303            .build()
3304    }
3305}
3306
3307/// Prune any unused source distributions from the cache.
3308pub fn prune(cache: &Cache) -> Result<Removal, Error> {
3309    let mut removal = cache.removal();
3310
3311    let bucket = cache.bucket(CacheBucket::SourceDistributions);
3312    if bucket.is_dir() {
3313        for entry in walkdir::WalkDir::new(bucket) {
3314            let entry = entry.map_err(Error::CacheWalk)?;
3315
3316            if !entry.file_type().is_dir() {
3317                continue;
3318            }
3319
3320            // If we find a `revision.http` file, read the pointer, and remove any extraneous
3321            // directories.
3322            let revision = entry.path().join("revision.http");
3323            if revision.is_file() {
3324                if let Ok(Some(pointer)) = HttpRevisionPointer::read_from(revision) {
3325                    // Remove all sibling directories that are not referenced by the pointer.
3326                    for sibling in entry.path().read_dir().map_err(Error::CacheRead)? {
3327                        let sibling = sibling.map_err(Error::CacheRead)?;
3328                        if sibling.file_type().map_err(Error::CacheRead)?.is_dir() {
3329                            let sibling_name = sibling.file_name();
3330                            if sibling_name != pointer.revision.id().as_str() {
3331                                debug!(
3332                                    "Removing dangling source revision: {}",
3333                                    sibling.path().display()
3334                                );
3335                                removal += cache
3336                                    .remove_path(sibling.path())
3337                                    .map_err(Error::CacheWrite)?;
3338                            }
3339                        }
3340                    }
3341                }
3342            }
3343
3344            // If we find a `revision.rev` file, read the pointer, and remove any extraneous
3345            // directories.
3346            let revision = entry.path().join("revision.rev");
3347            if revision.is_file() {
3348                if let Ok(Some(pointer)) = LocalRevisionPointer::read_from(revision) {
3349                    // Remove all sibling directories that are not referenced by the pointer.
3350                    for sibling in entry.path().read_dir().map_err(Error::CacheRead)? {
3351                        let sibling = sibling.map_err(Error::CacheRead)?;
3352                        if sibling.file_type().map_err(Error::CacheRead)?.is_dir() {
3353                            let sibling_name = sibling.file_name();
3354                            if sibling_name != pointer.revision.id().as_str() {
3355                                debug!(
3356                                    "Removing dangling source revision: {}",
3357                                    sibling.path().display()
3358                                );
3359                                removal += cache
3360                                    .remove_path(sibling.path())
3361                                    .map_err(Error::CacheWrite)?;
3362                            }
3363                        }
3364                    }
3365                }
3366            }
3367        }
3368    }
3369
3370    Ok(removal)
3371}
3372
3373/// The result of extracting statically available metadata from a source distribution.
3374#[derive(Debug)]
3375enum StaticMetadata {
3376    /// The metadata was found and successfully read.
3377    Some(ResolutionMetadata),
3378    /// The metadata was found, but it was ignored due to a dynamic version.
3379    Dynamic,
3380    /// The metadata was not found.
3381    None,
3382}
3383
3384impl StaticMetadata {
3385    /// Read the [`ResolutionMetadata`] from a source distribution.
3386    async fn read(
3387        source: &BuildableSource<'_>,
3388        source_root: &Path,
3389        subdirectory: Option<&Path>,
3390    ) -> Result<Self, Error> {
3391        // Attempt to read the `pyproject.toml`.
3392        let pyproject_toml = match read_pyproject_toml(source_root, subdirectory).await {
3393            Ok(pyproject_toml) => Some(pyproject_toml),
3394            Err(Error::MissingPyprojectToml) => {
3395                debug!("No `pyproject.toml` available for: {source}");
3396                None
3397            }
3398            Err(err) => return Err(err),
3399        };
3400
3401        // Determine whether the version is static or dynamic.
3402        let dynamic = pyproject_toml.as_ref().is_some_and(|pyproject_toml| {
3403            pyproject_toml.project.as_ref().is_some_and(|project| {
3404                project
3405                    .dynamic
3406                    .as_ref()
3407                    .is_some_and(|dynamic| dynamic.iter().any(|field| field == "version"))
3408            })
3409        });
3410
3411        // Attempt to read static metadata from the `pyproject.toml`.
3412        if let Some(pyproject_toml) = pyproject_toml {
3413            match ResolutionMetadata::parse_pyproject_toml(pyproject_toml, source.version()) {
3414                Ok(metadata) => {
3415                    debug!("Found static `pyproject.toml` for: {source}");
3416
3417                    // Validate the metadata, but ignore it if the metadata doesn't match.
3418                    match validate_metadata(source, &metadata) {
3419                        Ok(()) => {
3420                            return Ok(Self::Some(metadata));
3421                        }
3422                        Err(err) => {
3423                            debug!("Ignoring `pyproject.toml` for {source}: {err}");
3424                        }
3425                    }
3426                }
3427                Err(
3428                    err @ (uv_pypi_types::MetadataError::Pep508Error(_)
3429                    | uv_pypi_types::MetadataError::DynamicField(_)
3430                    | uv_pypi_types::MetadataError::FieldNotFound(_)
3431                    | uv_pypi_types::MetadataError::PoetrySyntax),
3432                ) => {
3433                    debug!("No static `pyproject.toml` available for: {source} ({err:?})");
3434                }
3435                Err(err) => return Err(Error::PyprojectToml(err)),
3436            }
3437        }
3438
3439        // If the source distribution is a source tree, avoid reading `PKG-INFO`, since it could be
3440        // out-of-date.
3441        if source.is_source_tree() {
3442            return Ok(if dynamic { Self::Dynamic } else { Self::None });
3443        }
3444
3445        // Attempt to read static metadata from the `PKG-INFO` file.
3446        match read_pkg_info(source_root, subdirectory).await {
3447            Ok(metadata) => {
3448                debug!("Found static `PKG-INFO` for: {source}");
3449
3450                // Validate the metadata, but ignore it if the metadata doesn't match.
3451                match validate_metadata(source, &metadata) {
3452                    Ok(()) => {
3453                        // If necessary, mark the metadata as dynamic.
3454                        let metadata = if dynamic {
3455                            ResolutionMetadata {
3456                                dynamic: true,
3457                                ..metadata
3458                            }
3459                        } else {
3460                            metadata
3461                        };
3462                        return Ok(Self::Some(metadata));
3463                    }
3464                    Err(err) => {
3465                        debug!("Ignoring `PKG-INFO` for {source}: {err}");
3466                    }
3467                }
3468            }
3469            Err(
3470                err @ (Error::MissingPkgInfo
3471                | Error::PkgInfo(
3472                    uv_pypi_types::MetadataError::Pep508Error(_)
3473                    | uv_pypi_types::MetadataError::DynamicField(_)
3474                    | uv_pypi_types::MetadataError::FieldNotFound(_)
3475                    | uv_pypi_types::MetadataError::UnsupportedMetadataVersion(_),
3476                )),
3477            ) => {
3478                debug!("No static `PKG-INFO` available for: {source} ({err:?})");
3479            }
3480            Err(err) => return Err(err),
3481        }
3482
3483        Ok(Self::None)
3484    }
3485}
3486
3487/// Returns `true` if a `pyproject.toml` has `tool.uv.sources`.
3488fn has_sources(content: &str) -> Result<bool, toml::de::Error> {
3489    #[derive(serde::Deserialize)]
3490    struct PyProjectToml {
3491        tool: Option<Tool>,
3492    }
3493
3494    #[derive(serde::Deserialize)]
3495    struct Tool {
3496        uv: Option<ToolUv>,
3497    }
3498
3499    #[derive(serde::Deserialize)]
3500    struct ToolUv {
3501        sources: Option<ToolUvSources>,
3502    }
3503
3504    let pyproject_toml =
3505        info_span!("toml::from_str has sources").in_scope(|| toml::from_str(content))?;
3506    if let PyProjectToml { tool: Some(tool) } = pyproject_toml {
3507        if let Some(uv) = tool.uv {
3508            if let Some(sources) = uv.sources {
3509                if !sources.inner().is_empty() {
3510                    return Ok(true);
3511                }
3512            }
3513        }
3514    }
3515
3516    Ok(false)
3517}
3518
3519/// Validate that the source distribution matches the built metadata.
3520fn validate_metadata(
3521    source: &BuildableSource<'_>,
3522    metadata: &ResolutionMetadata,
3523) -> Result<(), Error> {
3524    if let Some(name) = source.name() {
3525        if metadata.name != *name {
3526            return Err(Error::WheelMetadataNameMismatch {
3527                metadata: metadata.name.clone(),
3528                given: name.clone(),
3529            });
3530        }
3531    }
3532
3533    if let Some(version) = source.version() {
3534        if *version != metadata.version && *version != metadata.version.clone().without_local() {
3535            return Err(Error::WheelMetadataVersionMismatch {
3536                metadata: metadata.version.clone(),
3537                given: version.clone(),
3538            });
3539        }
3540    }
3541
3542    Ok(())
3543}
3544
3545/// Validate that the source distribution matches the built filename.
3546fn validate_filename(filename: &WheelFilename, metadata: &ResolutionMetadata) -> Result<(), Error> {
3547    if metadata.name != filename.name {
3548        return Err(Error::WheelFilenameNameMismatch {
3549            metadata: metadata.name.clone(),
3550            filename: filename.name.clone(),
3551        });
3552    }
3553
3554    if metadata.version != filename.version {
3555        return Err(Error::WheelFilenameVersionMismatch {
3556            metadata: metadata.version.clone(),
3557            filename: filename.version.clone(),
3558        });
3559    }
3560
3561    Ok(())
3562}
3563
3564/// A pointer to a source distribution revision in the cache, fetched from an HTTP archive.
3565///
3566/// Encoded with `MsgPack`, and represented on disk by a `.http` file.
3567#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
3568pub(crate) struct HttpRevisionPointer {
3569    revision: Revision,
3570}
3571
3572impl HttpRevisionPointer {
3573    /// Read an [`HttpRevisionPointer`] from the cache.
3574    pub(crate) fn read_from(path: impl AsRef<Path>) -> Result<Option<Self>, Error> {
3575        match fs_err::File::open(path.as_ref()) {
3576            Ok(file) => {
3577                let data = DataWithCachePolicy::from_reader(file)?.data;
3578                let revision = rmp_serde::from_slice::<Revision>(&data)?;
3579                Ok(Some(Self { revision }))
3580            }
3581            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
3582            Err(err) => Err(Error::CacheRead(err)),
3583        }
3584    }
3585
3586    /// Return the [`Revision`] from the pointer.
3587    pub(crate) fn into_revision(self) -> Revision {
3588        self.revision
3589    }
3590}
3591
3592/// A pointer to a source distribution revision in the cache, fetched from a local path.
3593///
3594/// Encoded with `MsgPack`, and represented on disk by a `.rev` file.
3595#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
3596pub(crate) struct LocalRevisionPointer {
3597    cache_info: CacheInfo,
3598    revision: Revision,
3599}
3600
3601impl LocalRevisionPointer {
3602    /// Read an [`LocalRevisionPointer`] from the cache.
3603    pub(crate) fn read_from(path: impl AsRef<Path>) -> Result<Option<Self>, Error> {
3604        match fs_err::read(path) {
3605            Ok(cached) => Ok(Some(rmp_serde::from_slice::<Self>(&cached)?)),
3606            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
3607            Err(err) => Err(Error::CacheRead(err)),
3608        }
3609    }
3610
3611    /// Write an [`LocalRevisionPointer`] to the cache.
3612    async fn write_to(&self, entry: &CacheEntry) -> Result<(), Error> {
3613        fs::create_dir_all(&entry.dir())
3614            .await
3615            .map_err(Error::CacheWrite)?;
3616        write_atomic(entry.path(), rmp_serde::to_vec(&self)?)
3617            .await
3618            .map_err(Error::CacheWrite)
3619    }
3620
3621    /// Return the [`CacheInfo`] for the pointer.
3622    pub(crate) fn cache_info(&self) -> &CacheInfo {
3623        &self.cache_info
3624    }
3625
3626    /// Return the [`Revision`] for the pointer.
3627    fn revision(&self) -> &Revision {
3628        &self.revision
3629    }
3630
3631    /// Return the [`Revision`] for the pointer.
3632    pub(crate) fn into_revision(self) -> Revision {
3633        self.revision
3634    }
3635}
3636
3637/// A pointer to a source distribution revision in the cache, fetched from a local path.
3638///
3639/// Encoded with `MsgPack`, and represented on disk by a `.rev` file.
3640#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
3641pub(crate) struct RevisionHashes {
3642    hashes: Vec<HashDigest>,
3643}
3644
3645impl RevisionHashes {
3646    /// Read an [`RevisionHashes`] from the cache.
3647    pub(crate) fn read_from(path: impl AsRef<Path>) -> Result<Option<Self>, Error> {
3648        match fs_err::read(path) {
3649            Ok(cached) => Ok(Some(rmp_serde::from_slice::<Self>(&cached)?)),
3650            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
3651            Err(err) => Err(Error::CacheRead(err)),
3652        }
3653    }
3654
3655    /// Write an [`LocalRevisionPointer`] to the cache.
3656    async fn write_to(&self, entry: &CacheEntry) -> Result<(), Error> {
3657        fs::create_dir_all(&entry.dir())
3658            .await
3659            .map_err(Error::CacheWrite)?;
3660        write_atomic(entry.path(), rmp_serde::to_vec(&self)?)
3661            .await
3662            .map_err(Error::CacheWrite)
3663    }
3664
3665    /// Return the computed hashes of the archive.
3666    pub(crate) fn into_hashes(self) -> HashDigests {
3667        HashDigests::from(self.hashes)
3668    }
3669}
3670
3671impl Hashed for RevisionHashes {
3672    fn hashes(&self) -> &[HashDigest] {
3673        &self.hashes
3674    }
3675}
3676
3677/// Read the [`ResolutionMetadata`] from a source distribution's `PKG-INFO` file, if it uses Metadata 2.2
3678/// or later _and_ none of the required fields (`Requires-Python`, `Requires-Dist`, and
3679/// `Provides-Extra`) are marked as dynamic.
3680async fn read_pkg_info(
3681    source_tree: &Path,
3682    subdirectory: Option<&Path>,
3683) -> Result<ResolutionMetadata, Error> {
3684    // Read the `PKG-INFO` file.
3685    let pkg_info = match subdirectory {
3686        Some(subdirectory) => source_tree.join(subdirectory).join("PKG-INFO"),
3687        None => source_tree.join("PKG-INFO"),
3688    };
3689    let content = match fs::read(pkg_info).await {
3690        Ok(content) => content,
3691        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
3692            return Err(Error::MissingPkgInfo);
3693        }
3694        Err(err) => return Err(Error::CacheRead(err)),
3695    };
3696
3697    // Parse the metadata.
3698    let metadata = ResolutionMetadata::parse_pkg_info(&content).map_err(Error::PkgInfo)?;
3699
3700    Ok(metadata)
3701}
3702
3703/// Read the [`ResolutionMetadata`] from a source distribution's `pyproject.toml` file, if it defines static
3704/// metadata consistent with PEP 621.
3705async fn read_pyproject_toml(
3706    source_tree: &Path,
3707    subdirectory: Option<&Path>,
3708) -> Result<PyProjectToml, Error> {
3709    // Read the `pyproject.toml` file.
3710    let pyproject_toml = match subdirectory {
3711        Some(subdirectory) => source_tree.join(subdirectory).join("pyproject.toml"),
3712        None => source_tree.join("pyproject.toml"),
3713    };
3714    let content = match fs::read_to_string(&pyproject_toml).await {
3715        Ok(content) => content,
3716        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
3717            return Err(Error::MissingPyprojectToml);
3718        }
3719        Err(err) => return Err(Error::CacheRead(err)),
3720    };
3721
3722    let pyproject_toml = PyProjectToml::from_toml(&content, pyproject_toml.simplified_display())?;
3723
3724    Ok(pyproject_toml)
3725}
3726
3727/// Wheel metadata stored in the source distribution cache.
3728#[derive(Debug, Clone)]
3729struct CachedMetadata(ResolutionMetadata);
3730
3731impl CachedMetadata {
3732    /// Read an existing cached [`ResolutionMetadata`], if it exists.
3733    async fn read(cache_entry: &CacheEntry) -> Result<Option<Self>, Error> {
3734        match fs::read(&cache_entry.path()).await {
3735            Ok(cached) => Ok(Some(Self(rmp_serde::from_slice(&cached)?))),
3736            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
3737            Err(err) => Err(Error::CacheRead(err)),
3738        }
3739    }
3740
3741    /// Returns `true` if the metadata matches the given package name and version.
3742    fn matches(&self, name: Option<&PackageName>, version: Option<&Version>) -> bool {
3743        name.is_none_or(|name| self.0.name == *name)
3744            && version.is_none_or(|version| self.0.version == *version)
3745    }
3746}
3747
3748impl From<CachedMetadata> for ResolutionMetadata {
3749    fn from(value: CachedMetadata) -> Self {
3750        value.0
3751    }
3752}
3753
3754/// Read the [`ResolutionMetadata`] from a built wheel.
3755fn read_wheel_metadata(
3756    filename: &WheelFilename,
3757    wheel: &Path,
3758) -> Result<ResolutionMetadata, Error> {
3759    let file = fs_err::File::open(wheel).map_err(Error::CacheRead)?;
3760    let reader = std::io::BufReader::new(file);
3761    let dist_info = read_archive_metadata(filename, reader)
3762        .map_err(|err| Error::WheelMetadata(wheel.to_path_buf(), Box::new(err)))?;
3763    Ok(ResolutionMetadata::parse_metadata(&dist_info)?)
3764}