uv_distribution/index/
built_wheel_index.rs

1use std::borrow::Cow;
2
3use uv_cache::{Cache, CacheBucket, CacheShard, WheelCache};
4use uv_cache_info::CacheInfo;
5use uv_distribution_types::{
6    BuildInfo, BuildVariables, ConfigSettings, DirectUrlSourceDist, DirectorySourceDist,
7    ExtraBuildRequirement, ExtraBuildRequires, ExtraBuildVariables, GitSourceDist, Hashed,
8    PackageConfigSettings, PathSourceDist,
9};
10use uv_normalize::PackageName;
11use uv_platform_tags::Tags;
12use uv_pypi_types::HashDigests;
13use uv_types::HashStrategy;
14
15use crate::Error;
16use crate::index::cached_wheel::{CachedWheel, ResolvedWheel};
17use crate::source::{HTTP_REVISION, HttpRevisionPointer, LOCAL_REVISION, LocalRevisionPointer};
18
19/// A local index of built distributions for a specific source distribution.
20#[derive(Debug)]
21pub struct BuiltWheelIndex<'a> {
22    cache: &'a Cache,
23    tags: &'a Tags,
24    hasher: &'a HashStrategy,
25    config_settings: &'a ConfigSettings,
26    config_settings_package: &'a PackageConfigSettings,
27    extra_build_requires: &'a ExtraBuildRequires,
28    extra_build_variables: &'a ExtraBuildVariables,
29}
30
31impl<'a> BuiltWheelIndex<'a> {
32    /// Initialize an index of built distributions.
33    pub fn new(
34        cache: &'a Cache,
35        tags: &'a Tags,
36        hasher: &'a HashStrategy,
37        config_settings: &'a ConfigSettings,
38        config_settings_package: &'a PackageConfigSettings,
39        extra_build_requires: &'a ExtraBuildRequires,
40        extra_build_variables: &'a ExtraBuildVariables,
41    ) -> Self {
42        Self {
43            cache,
44            tags,
45            hasher,
46            config_settings,
47            config_settings_package,
48            extra_build_requires,
49            extra_build_variables,
50        }
51    }
52
53    /// Return the most compatible [`CachedWheel`] for a given source distribution at a direct URL.
54    ///
55    /// This method does not perform any freshness checks and assumes that the source distribution
56    /// is already up-to-date.
57    pub fn url(&self, source_dist: &DirectUrlSourceDist) -> Result<Option<CachedWheel>, Error> {
58        // For direct URLs, cache directly under the hash of the URL itself.
59        let cache_shard = self.cache.shard(
60            CacheBucket::SourceDistributions,
61            WheelCache::Url(source_dist.url.raw()).root(),
62        );
63
64        // Read the revision from the cache.
65        let Some(pointer) = HttpRevisionPointer::read_from(cache_shard.entry(HTTP_REVISION))?
66        else {
67            return Ok(None);
68        };
69
70        // Enforce hash-checking by omitting any wheels that don't satisfy the required hashes.
71        let revision = pointer.into_revision();
72        if !revision.satisfies(self.hasher.get(source_dist)) {
73            return Ok(None);
74        }
75
76        let cache_shard = cache_shard.shard(revision.id());
77
78        // If there are build settings, we need to scope to a cache shard.
79        let config_settings = self.config_settings_for(&source_dist.name);
80        let extra_build_deps = self.extra_build_requires_for(&source_dist.name);
81        let extra_build_vars = self.extra_build_variables_for(&source_dist.name);
82        let build_info =
83            BuildInfo::from_settings(&config_settings, extra_build_deps, extra_build_vars);
84        let cache_shard = build_info
85            .cache_shard()
86            .map(|digest| cache_shard.shard(digest))
87            .unwrap_or(cache_shard);
88
89        Ok(self.find(&cache_shard).map(|wheel| {
90            CachedWheel::from_entry(
91                wheel,
92                revision.into_hashes(),
93                CacheInfo::default(),
94                build_info,
95            )
96        }))
97    }
98
99    /// Return the most compatible [`CachedWheel`] for a given source distribution at a local path.
100    pub fn path(&self, source_dist: &PathSourceDist) -> Result<Option<CachedWheel>, Error> {
101        let cache_shard = self.cache.shard(
102            CacheBucket::SourceDistributions,
103            WheelCache::Path(&source_dist.url).root(),
104        );
105
106        // Read the revision from the cache.
107        let Some(pointer) = LocalRevisionPointer::read_from(cache_shard.entry(LOCAL_REVISION))?
108        else {
109            return Ok(None);
110        };
111
112        // If the distribution is stale, omit it from the index.
113        let cache_info =
114            CacheInfo::from_file(&source_dist.install_path).map_err(Error::CacheRead)?;
115        if cache_info != *pointer.cache_info() {
116            return Ok(None);
117        }
118
119        // Enforce hash-checking by omitting any wheels that don't satisfy the required hashes.
120        let revision = pointer.into_revision();
121        if !revision.satisfies(self.hasher.get(source_dist)) {
122            return Ok(None);
123        }
124
125        let cache_shard = cache_shard.shard(revision.id());
126
127        // If there are build settings, we need to scope to a cache shard.
128        let config_settings = self.config_settings_for(&source_dist.name);
129        let extra_build_deps = self.extra_build_requires_for(&source_dist.name);
130        let extra_build_vars = self.extra_build_variables_for(&source_dist.name);
131        let build_info =
132            BuildInfo::from_settings(&config_settings, extra_build_deps, extra_build_vars);
133        let cache_shard = build_info
134            .cache_shard()
135            .map(|digest| cache_shard.shard(digest))
136            .unwrap_or(cache_shard);
137
138        Ok(self.find(&cache_shard).map(|wheel| {
139            CachedWheel::from_entry(wheel, revision.into_hashes(), cache_info, build_info)
140        }))
141    }
142
143    /// Return the most compatible [`CachedWheel`] for a given source distribution built from a
144    /// local directory (source tree).
145    pub fn directory(
146        &self,
147        source_dist: &DirectorySourceDist,
148    ) -> Result<Option<CachedWheel>, Error> {
149        let cache_shard = self.cache.shard(
150            CacheBucket::SourceDistributions,
151            if source_dist.editable.unwrap_or(false) {
152                WheelCache::Editable(&source_dist.url).root()
153            } else {
154                WheelCache::Path(&source_dist.url).root()
155            },
156        );
157
158        // Read the revision from the cache.
159        let Some(pointer) = LocalRevisionPointer::read_from(cache_shard.entry(LOCAL_REVISION))?
160        else {
161            return Ok(None);
162        };
163
164        // If the distribution is stale, omit it from the index.
165        let cache_info = CacheInfo::from_directory(&source_dist.install_path)?;
166        if cache_info != *pointer.cache_info() {
167            return Ok(None);
168        }
169
170        // Enforce hash-checking by omitting any wheels that don't satisfy the required hashes.
171        let revision = pointer.into_revision();
172        if !revision.satisfies(self.hasher.get(source_dist)) {
173            return Ok(None);
174        }
175
176        let cache_shard = cache_shard.shard(revision.id());
177
178        // If there are build settings, we need to scope to a cache shard.
179        let config_settings = self.config_settings_for(&source_dist.name);
180        let extra_build_deps = self.extra_build_requires_for(&source_dist.name);
181        let extra_build_vars = self.extra_build_variables_for(&source_dist.name);
182        let build_info =
183            BuildInfo::from_settings(&config_settings, extra_build_deps, extra_build_vars);
184        let cache_shard = build_info
185            .cache_shard()
186            .map(|digest| cache_shard.shard(digest))
187            .unwrap_or(cache_shard);
188
189        Ok(self.find(&cache_shard).map(|wheel| {
190            CachedWheel::from_entry(wheel, revision.into_hashes(), cache_info, build_info)
191        }))
192    }
193
194    /// Return the most compatible [`CachedWheel`] for a given source distribution at a git URL.
195    pub fn git(&self, source_dist: &GitSourceDist) -> Option<CachedWheel> {
196        // Enforce hash-checking, which isn't supported for Git distributions.
197        if self.hasher.get(source_dist).is_validate() {
198            return None;
199        }
200
201        let git_sha = source_dist.git.precise()?;
202
203        let cache_shard = self.cache.shard(
204            CacheBucket::SourceDistributions,
205            WheelCache::Git(&source_dist.url, git_sha.as_short_str()).root(),
206        );
207
208        // If there are build settings, we need to scope to a cache shard.
209        let config_settings = self.config_settings_for(&source_dist.name);
210        let extra_build_deps = self.extra_build_requires_for(&source_dist.name);
211        let extra_build_vars = self.extra_build_variables_for(&source_dist.name);
212        let build_info =
213            BuildInfo::from_settings(&config_settings, extra_build_deps, extra_build_vars);
214        let cache_shard = build_info
215            .cache_shard()
216            .map(|digest| cache_shard.shard(digest))
217            .unwrap_or(cache_shard);
218
219        self.find(&cache_shard).map(|wheel| {
220            CachedWheel::from_entry(
221                wheel,
222                HashDigests::empty(),
223                CacheInfo::default(),
224                build_info,
225            )
226        })
227    }
228
229    /// Find the "best" distribution in the index for a given source distribution.
230    ///
231    /// This lookup prefers newer versions over older versions, and aims to maximize compatibility
232    /// with the target platform.
233    ///
234    /// The `shard` should point to a directory containing the built distributions for a specific
235    /// source distribution. For example, given the built wheel cache structure:
236    /// ```text
237    /// built-wheels-v0/
238    /// └── pypi
239    ///     └── django-allauth-0.51.0.tar.gz
240    ///         ├── django_allauth-0.51.0-py3-none-any.whl
241    ///         └── metadata.json
242    /// ```
243    ///
244    /// The `shard` should be `built-wheels-v0/pypi/django-allauth-0.51.0.tar.gz`.
245    fn find(&self, shard: &CacheShard) -> Option<ResolvedWheel> {
246        let mut candidate: Option<ResolvedWheel> = None;
247
248        // Unzipped wheels are stored as symlinks into the archive directory.
249        for wheel_dir in uv_fs::entries(shard).ok().into_iter().flatten() {
250            // Ignore any `.lock` files.
251            if wheel_dir
252                .extension()
253                .is_some_and(|ext| ext.eq_ignore_ascii_case("lock"))
254            {
255                continue;
256            }
257
258            match ResolvedWheel::from_built_source(&wheel_dir, self.cache) {
259                None => {}
260                Some(dist_info) => {
261                    // Pick the wheel with the highest priority
262                    let compatibility = dist_info.filename.compatibility(self.tags);
263
264                    // Only consider wheels that are compatible with our tags.
265                    if !compatibility.is_compatible() {
266                        continue;
267                    }
268
269                    if let Some(existing) = candidate.as_ref() {
270                        // Override if the wheel is newer, or "more" compatible.
271                        if dist_info.filename.version > existing.filename.version
272                            || compatibility > existing.filename.compatibility(self.tags)
273                        {
274                            candidate = Some(dist_info);
275                        }
276                    } else {
277                        candidate = Some(dist_info);
278                    }
279                }
280            }
281        }
282
283        candidate
284    }
285
286    /// Determine the [`ConfigSettings`] for the given package name.
287    fn config_settings_for(&self, name: &PackageName) -> Cow<'_, ConfigSettings> {
288        if let Some(package_settings) = self.config_settings_package.get(name) {
289            Cow::Owned(package_settings.clone().merge(self.config_settings.clone()))
290        } else {
291            Cow::Borrowed(self.config_settings)
292        }
293    }
294
295    /// Determine the extra build requirements for the given package name.
296    fn extra_build_requires_for(&self, name: &PackageName) -> &[ExtraBuildRequirement] {
297        self.extra_build_requires
298            .get(name)
299            .map(Vec::as_slice)
300            .unwrap_or(&[])
301    }
302
303    /// Determine the extra build variables for the given package name.
304    fn extra_build_variables_for(&self, name: &PackageName) -> Option<&BuildVariables> {
305        self.extra_build_variables.get(name)
306    }
307}