Skip to main content

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