Skip to main content

uv_distribution/index/
registry_wheel_index.rs

1use std::borrow::Cow;
2use std::collections::hash_map::Entry;
3
4use rustc_hash::{FxHashMap, FxHashSet};
5
6use uv_cache::{Cache, CacheBucket, WheelCache};
7use uv_cache_info::CacheInfo;
8use uv_distribution_filename::WheelFilename;
9use uv_distribution_types::{
10    BuildInfo, BuildVariables, CachedRegistryDist, ConfigSettings, ExtraBuildRequirement,
11    ExtraBuildRequires, ExtraBuildVariables, Hashed, Index, IndexLocations, IndexUrl,
12    PackageConfigSettings, RegistryBuiltDist, RegistrySourceDist,
13};
14use uv_fs::{directories, files};
15use uv_normalize::PackageName;
16use uv_pep440::Version;
17use uv_platform_tags::Tags;
18use uv_types::HashStrategy;
19
20use crate::index::cached_wheel::{CachedWheel, ResolvedWheel};
21use crate::source::{HTTP_REVISION, HttpRevisionPointer, LOCAL_REVISION, LocalRevisionPointer};
22
23/// An entry in the [`RegistryWheelIndex`].
24#[derive(Debug, Clone, Hash, PartialEq, Eq)]
25pub struct IndexEntry<'index> {
26    /// The cached distribution.
27    dist: CachedRegistryDist,
28    /// Whether the wheel was built from source (true), or downloaded from the registry directly (false).
29    built: bool,
30    /// The index from which the wheel was downloaded.
31    index: &'index Index,
32}
33
34impl IndexEntry<'_> {
35    /// The index from which the wheel was downloaded.
36    pub fn index(&self) -> &Index {
37        self.index
38    }
39
40    /// Whether the wheel was built from source.
41    pub fn is_built(&self) -> bool {
42        self.built
43    }
44
45    /// The cached distribution.
46    pub fn dist(&self) -> &CachedRegistryDist {
47        &self.dist
48    }
49
50    fn matches_wheel(
51        &self,
52        index: &IndexUrl,
53        filename: &WheelFilename,
54        no_build: bool,
55        no_binary: bool,
56    ) -> bool {
57        self.matches_index_and_build_policy(index, no_build, no_binary)
58            && self.dist.filename == *filename
59    }
60
61    fn matches_source(
62        &self,
63        index: &IndexUrl,
64        name: &PackageName,
65        version: &Version,
66        no_build: bool,
67        no_binary: bool,
68    ) -> bool {
69        self.matches_index_and_build_policy(index, no_build, no_binary)
70            && self.dist.filename.name == *name
71            && self.dist.filename.version == *version
72    }
73
74    fn matches_index_and_build_policy(
75        &self,
76        index: &IndexUrl,
77        no_build: bool,
78        no_binary: bool,
79    ) -> bool {
80        if *self.index.url() != *index {
81            return false;
82        }
83        if self.built { !no_build } else { !no_binary }
84    }
85}
86
87/// A local index of distributions that originate from a registry, like `PyPI`.
88#[derive(Debug)]
89pub struct RegistryWheelIndex<'a> {
90    cache: &'a Cache,
91    tags: &'a Tags,
92    index_locations: &'a IndexLocations,
93    hasher: &'a HashStrategy,
94    index: FxHashMap<&'a PackageName, Vec<IndexEntry<'a>>>,
95    config_settings: &'a ConfigSettings,
96    config_settings_package: &'a PackageConfigSettings,
97    extra_build_requires: &'a ExtraBuildRequires,
98    extra_build_variables: &'a ExtraBuildVariables,
99}
100
101impl<'a> RegistryWheelIndex<'a> {
102    /// Initialize an index of registry distributions.
103    pub fn new(
104        cache: &'a Cache,
105        tags: &'a Tags,
106        index_locations: &'a IndexLocations,
107        hasher: &'a HashStrategy,
108        config_settings: &'a ConfigSettings,
109        config_settings_package: &'a PackageConfigSettings,
110        extra_build_requires: &'a ExtraBuildRequires,
111        extra_build_variables: &'a ExtraBuildVariables,
112    ) -> Self {
113        Self {
114            cache,
115            tags,
116            index_locations,
117            hasher,
118            config_settings,
119            config_settings_package,
120            extra_build_requires,
121            extra_build_variables,
122            index: FxHashMap::default(),
123        }
124    }
125
126    /// Return a cached wheel that satisfies a registry wheel requirement.
127    pub fn wheel(
128        &mut self,
129        wheel: &'a RegistryBuiltDist,
130        no_build: bool,
131        no_binary: bool,
132    ) -> Option<&CachedRegistryDist> {
133        let wheel = wheel.best_wheel();
134        self.get(&wheel.filename.name).find_map(|entry| {
135            entry
136                .matches_wheel(&wheel.index, &wheel.filename, no_build, no_binary)
137                .then_some(&entry.dist)
138        })
139    }
140
141    /// Return a cached wheel that satisfies a registry source distribution requirement.
142    pub fn source(
143        &mut self,
144        source: &'a RegistrySourceDist,
145        no_build: bool,
146        no_binary: bool,
147    ) -> Option<&CachedRegistryDist> {
148        self.get(&source.name).find_map(|entry| {
149            entry
150                .matches_source(
151                    &source.index,
152                    &source.name,
153                    &source.version,
154                    no_build,
155                    no_binary,
156                )
157                .then_some(&entry.dist)
158        })
159    }
160
161    /// Return an iterator over available wheels for a given package.
162    ///
163    /// If the package is not yet indexed, this will index the package by reading from the cache.
164    pub fn get(&mut self, name: &'a PackageName) -> impl Iterator<Item = &IndexEntry<'_>> {
165        self.get_impl(name).iter().rev()
166    }
167
168    /// Get an entry in the index.
169    fn get_impl(&mut self, name: &'a PackageName) -> &[IndexEntry<'_>] {
170        (match self.index.entry(name) {
171            Entry::Occupied(entry) => entry.into_mut(),
172            Entry::Vacant(entry) => entry.insert(Self::index(
173                name,
174                self.cache,
175                self.tags,
176                self.index_locations,
177                self.hasher,
178                self.config_settings,
179                self.config_settings_package,
180                self.extra_build_requires,
181                self.extra_build_variables,
182            )),
183        }) as _
184    }
185
186    /// Add a package to the index by reading from the cache.
187    fn index<'index>(
188        package: &PackageName,
189        cache: &Cache,
190        tags: &Tags,
191        index_locations: &'index IndexLocations,
192        hasher: &HashStrategy,
193        config_settings: &ConfigSettings,
194        config_settings_package: &PackageConfigSettings,
195        extra_build_requires: &ExtraBuildRequires,
196        extra_build_variables: &ExtraBuildVariables,
197    ) -> Vec<IndexEntry<'index>> {
198        let mut entries = vec![];
199
200        let mut seen = FxHashSet::default();
201        for index in index_locations.allowed_indexes() {
202            if !seen.insert(index.url()) {
203                continue;
204            }
205
206            // Index all the wheels that were downloaded directly from the registry.
207            let wheel_dir = cache.shard(
208                CacheBucket::Wheels,
209                WheelCache::Index(index.url()).wheel_dir(package.as_ref()),
210            );
211
212            // For registry wheels, the cache structure is: `<index>/<package-name>/<wheel>.http`
213            // or `<index>/<package-name>/<version>/<wheel>.rev`.
214            for file in files(&wheel_dir).ok().into_iter().flatten() {
215                match index.url() {
216                    // Add files from remote registries.
217                    IndexUrl::Pypi(_) | IndexUrl::Url(_) => {
218                        if file
219                            .extension()
220                            .is_some_and(|ext| ext.eq_ignore_ascii_case("http"))
221                        {
222                            if let Some(wheel) =
223                                CachedWheel::from_http_pointer(wheel_dir.join(file), cache)
224                            {
225                                if wheel.filename.compatibility(tags).is_compatible() {
226                                    // Enforce hash-checking based on the built distribution.
227                                    if wheel.satisfies(
228                                        hasher.get_package(
229                                            &wheel.filename.name,
230                                            &wheel.filename.version,
231                                        ),
232                                    ) {
233                                        entries.push(IndexEntry {
234                                            dist: wheel.into_registry_dist(),
235                                            index,
236                                            built: false,
237                                        });
238                                    }
239                                }
240                            }
241                        }
242                    }
243                    // Add files from local registries (e.g., `--find-links`).
244                    IndexUrl::Path(_) => {
245                        if file
246                            .extension()
247                            .is_some_and(|ext| ext.eq_ignore_ascii_case("rev"))
248                        {
249                            if let Some(wheel) =
250                                CachedWheel::from_local_pointer(wheel_dir.join(file), cache)
251                            {
252                                if wheel.filename.compatibility(tags).is_compatible() {
253                                    // Enforce hash-checking based on the built distribution.
254                                    if wheel.satisfies(
255                                        hasher.get_package(
256                                            &wheel.filename.name,
257                                            &wheel.filename.version,
258                                        ),
259                                    ) {
260                                        entries.push(IndexEntry {
261                                            dist: wheel.into_registry_dist(),
262                                            index,
263                                            built: false,
264                                        });
265                                    }
266                                }
267                            }
268                        }
269                    }
270                }
271            }
272
273            // Index all the built wheels, created by downloading and building source distributions
274            // from the registry.
275            let cache_shard = cache.shard(
276                CacheBucket::SourceDistributions,
277                WheelCache::Index(index.url()).wheel_dir(package.as_ref()),
278            );
279
280            // For registry source distributions, the cache structure is: `<index>/<package-name>/<version>/`.
281            for shard in directories(&cache_shard).ok().into_iter().flatten() {
282                let cache_shard = cache_shard.shard(shard);
283
284                // Read the revision from the cache.
285                let revision = match index.url() {
286                    // Add files from remote registries.
287                    IndexUrl::Pypi(_) | IndexUrl::Url(_) => {
288                        let revision_entry = cache_shard.entry(HTTP_REVISION);
289                        if let Ok(Some(pointer)) = HttpRevisionPointer::read_from(revision_entry) {
290                            Some(pointer.into_revision())
291                        } else {
292                            None
293                        }
294                    }
295                    // Add files from local registries (e.g., `--find-links`).
296                    IndexUrl::Path(_) => {
297                        let revision_entry = cache_shard.entry(LOCAL_REVISION);
298                        if let Ok(Some(pointer)) = LocalRevisionPointer::read_from(revision_entry) {
299                            Some(pointer.into_revision())
300                        } else {
301                            None
302                        }
303                    }
304                };
305
306                if let Some(revision) = revision {
307                    let cache_shard = cache_shard.shard(revision.id());
308
309                    // If there are build settings, we need to scope to a cache shard.
310                    let extra_build_deps =
311                        Self::extra_build_requires_for(package, extra_build_requires);
312                    let extra_build_vars =
313                        Self::extra_build_variables_for(package, extra_build_variables);
314                    let config_settings = Self::config_settings_for(
315                        package,
316                        config_settings,
317                        config_settings_package,
318                    );
319                    let build_info = BuildInfo::from_settings(
320                        config_settings.into_owned(),
321                        extra_build_deps.to_vec(),
322                        extra_build_vars.cloned(),
323                    );
324                    let cache_shard = build_info
325                        .cache_shard()
326                        .map(|digest| cache_shard.shard(digest))
327                        .unwrap_or(cache_shard);
328
329                    for wheel_dir in uv_fs::entries(cache_shard).ok().into_iter().flatten() {
330                        // Ignore any `.lock` files.
331                        if wheel_dir
332                            .extension()
333                            .is_some_and(|ext| ext.eq_ignore_ascii_case("lock"))
334                        {
335                            continue;
336                        }
337
338                        if let Some(wheel) = ResolvedWheel::from_built_source(wheel_dir, cache) {
339                            if wheel.filename.compatibility(tags).is_compatible() {
340                                // Enforce hash-checking based on the source distribution.
341                                if revision.satisfies(
342                                    hasher
343                                        .get_package(&wheel.filename.name, &wheel.filename.version),
344                                ) {
345                                    let wheel = CachedWheel::from_entry(
346                                        wheel,
347                                        revision.hashes().into(),
348                                        CacheInfo::default(),
349                                        build_info.clone(),
350                                    );
351                                    entries.push(IndexEntry {
352                                        dist: wheel.into_registry_dist(),
353                                        index,
354                                        built: true,
355                                    });
356                                }
357                            }
358                        }
359                    }
360                }
361            }
362        }
363
364        // Sort the cached distributions by (1) version, (2) compatibility, and (3) build status.
365        // We want the highest versions, with the greatest compatibility, that were built from source.
366        // at the end of the list.
367        entries.sort_unstable_by(|a, b| {
368            a.dist
369                .filename
370                .version
371                .cmp(&b.dist.filename.version)
372                .then_with(|| {
373                    a.dist
374                        .filename
375                        .compatibility(tags)
376                        .cmp(&b.dist.filename.compatibility(tags))
377                        .then_with(|| a.built.cmp(&b.built))
378                })
379        });
380
381        entries
382    }
383
384    /// Determine the [`ConfigSettings`] for the given package name.
385    fn config_settings_for<'settings>(
386        name: &PackageName,
387        config_settings: &'settings ConfigSettings,
388        config_settings_package: &PackageConfigSettings,
389    ) -> Cow<'settings, ConfigSettings> {
390        if let Some(package_settings) = config_settings_package.get(name) {
391            Cow::Owned(package_settings.clone().merge(config_settings.clone()))
392        } else {
393            Cow::Borrowed(config_settings)
394        }
395    }
396
397    /// Determine the extra build requirements for the given package name.
398    fn extra_build_requires_for<'settings>(
399        name: &PackageName,
400        extra_build_requires: &'settings ExtraBuildRequires,
401    ) -> &'settings [ExtraBuildRequirement] {
402        extra_build_requires
403            .get(name)
404            .map(Vec::as_slice)
405            .unwrap_or(&[])
406    }
407
408    /// Determine the extra build variables for the given package name.
409    fn extra_build_variables_for<'settings>(
410        name: &PackageName,
411        extra_build_variables: &'settings ExtraBuildVariables,
412    ) -> Option<&'settings BuildVariables> {
413        extra_build_variables.get(name)
414    }
415}