Skip to main content

uv_resolver/
preferences.rs

1use std::str::FromStr;
2
3use rustc_hash::FxHashMap;
4use tracing::trace;
5
6use uv_distribution_types::{IndexUrl, InstalledDist, InstalledDistKind};
7use uv_normalize::PackageName;
8use uv_pep440::{Operator, Version};
9use uv_pep508::{MarkerTree, VersionOrUrl};
10use uv_pypi_types::{HashDigest, HashDigests, HashError};
11use uv_requirements_txt::{RequirementEntry, RequirementsTxtRequirement};
12
13use crate::ResolverEnvironment;
14use crate::universal_marker::UniversalMarker;
15
16#[derive(thiserror::Error, Debug)]
17pub enum PreferenceError {
18    #[error(transparent)]
19    Hash(#[from] HashError),
20}
21
22/// A pinned requirement, as extracted from a `requirements.txt` file.
23#[derive(Clone, Debug)]
24pub struct Preference {
25    name: PackageName,
26    version: Version,
27    /// The markers on the requirement itself (those after the semicolon).
28    marker: MarkerTree,
29    /// The index URL of the package, if any.
30    index: PreferenceIndex,
31    /// If coming from a package with diverging versions, the markers of the forks this preference
32    /// is part of, otherwise `None`.
33    fork_markers: Vec<UniversalMarker>,
34    hashes: HashDigests,
35    /// The source of the preference.
36    source: PreferenceSource,
37}
38
39impl Preference {
40    /// Create a [`Preference`] from a [`RequirementEntry`].
41    pub fn from_entry(entry: RequirementEntry) -> Result<Option<Self>, PreferenceError> {
42        let RequirementsTxtRequirement::Named(requirement) = entry.requirement else {
43            return Ok(None);
44        };
45
46        let Some(VersionOrUrl::VersionSpecifier(specifier)) = requirement.version_or_url.as_ref()
47        else {
48            trace!("Excluding {requirement} from preferences due to non-version specifier");
49            return Ok(None);
50        };
51
52        let [specifier] = specifier.as_ref() else {
53            trace!("Excluding {requirement} from preferences due to multiple version specifiers");
54            return Ok(None);
55        };
56
57        if *specifier.operator() != Operator::Equal {
58            trace!("Excluding {requirement} from preferences due to inexact version specifier");
59            return Ok(None);
60        }
61
62        Ok(Some(Self {
63            name: requirement.name,
64            version: specifier.version().clone(),
65            marker: requirement.marker,
66            // `requirements.txt` doesn't have fork annotations.
67            fork_markers: vec![],
68            // `requirements.txt` doesn't allow a requirement to specify an explicit index.
69            index: PreferenceIndex::Any,
70            hashes: entry
71                .hashes
72                .iter()
73                .map(String::as_str)
74                .map(HashDigest::from_str)
75                .collect::<Result<_, _>>()?,
76            source: PreferenceSource::RequirementsTxt,
77        }))
78    }
79
80    /// Create a preference for a version recorded in a lockfile.
81    pub fn from_locked(
82        name: PackageName,
83        version: Version,
84        index: Option<IndexUrl>,
85        fork_markers: Vec<UniversalMarker>,
86    ) -> Self {
87        Self {
88            name,
89            version,
90            marker: MarkerTree::TRUE,
91            index: PreferenceIndex::from(index),
92            fork_markers,
93            hashes: HashDigests::empty(),
94            source: PreferenceSource::Lock,
95        }
96    }
97
98    /// Create a [`Preference`] from an installed distribution.
99    pub fn from_installed(dist: &InstalledDist) -> Option<Self> {
100        let InstalledDistKind::Registry(dist) = &dist.kind else {
101            return None;
102        };
103        Some(Self {
104            name: dist.name.clone(),
105            version: dist.version.clone(),
106            marker: MarkerTree::TRUE,
107            index: PreferenceIndex::Any,
108            fork_markers: vec![],
109            hashes: HashDigests::empty(),
110            source: PreferenceSource::Environment,
111        })
112    }
113
114    /// Return the [`PackageName`] of the package for this [`Preference`].
115    pub fn name(&self) -> &PackageName {
116        &self.name
117    }
118}
119
120#[derive(Debug, Clone)]
121pub(crate) enum PreferenceIndex {
122    /// The preference should match to any index.
123    Any,
124    /// The preference should match to an implicit index.
125    Implicit,
126    /// The preference should match to a specific index.
127    Explicit(IndexUrl),
128}
129
130impl PreferenceIndex {
131    /// Returns `true` if the preference matches the given explicit [`IndexUrl`].
132    pub(crate) fn matches(&self, index: &IndexUrl) -> bool {
133        match self {
134            Self::Any => true,
135            Self::Implicit => false,
136            Self::Explicit(preference) => {
137                // Preferences are stored in the lockfile without credentials, while the index URL
138                // in locations such as `pyproject.toml` may contain credentials.
139                *preference.url() == *index.without_credentials()
140            }
141        }
142    }
143}
144
145impl From<Option<IndexUrl>> for PreferenceIndex {
146    fn from(index: Option<IndexUrl>) -> Self {
147        match index {
148            Some(index) => Self::Explicit(index),
149            None => Self::Implicit,
150        }
151    }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub(crate) enum PreferenceSource {
156    /// The preference is from an installed package in the environment.
157    Environment,
158    /// The preference is from a `uv.ock` file.
159    Lock,
160    /// The preference is from a `requirements.txt` file.
161    RequirementsTxt,
162    /// The preference is from the current solve.
163    Resolver,
164}
165
166#[derive(Debug, Clone)]
167pub(crate) struct Entry {
168    marker: UniversalMarker,
169    index: PreferenceIndex,
170    pin: Pin,
171    source: PreferenceSource,
172}
173
174impl Entry {
175    /// Return the [`UniversalMarker`] associated with the entry.
176    pub(crate) fn marker(&self) -> &UniversalMarker {
177        &self.marker
178    }
179
180    /// Return the [`IndexUrl`] associated with the entry, if any.
181    pub(crate) fn index(&self) -> &PreferenceIndex {
182        &self.index
183    }
184
185    /// Return the pinned data associated with the entry.
186    pub(crate) fn pin(&self) -> &Pin {
187        &self.pin
188    }
189
190    /// Return the source of the entry.
191    pub(crate) fn source(&self) -> PreferenceSource {
192        self.source
193    }
194}
195
196/// A set of pinned packages that should be preserved during resolution, if possible.
197///
198/// The marker is the marker of the fork that resolved to the pin, if any.
199///
200/// Preferences should be prioritized first by whether their marker matches and then by the order
201/// they are stored, so that a lockfile has higher precedence than sibling forks.
202#[derive(Debug, Clone, Default)]
203pub struct Preferences(FxHashMap<PackageName, Vec<Entry>>);
204
205impl Preferences {
206    /// Create a map of pinned packages from an iterator of [`Preference`] entries.
207    ///
208    /// The provided [`ResolverEnvironment`] will be used to filter the preferences
209    /// to an applicable subset.
210    pub fn from_iter(
211        preferences: impl IntoIterator<Item = Preference>,
212        env: &ResolverEnvironment,
213    ) -> Self {
214        let mut map = FxHashMap::<PackageName, Vec<_>>::default();
215        for preference in preferences {
216            // Filter non-matching preferences when resolving for an environment.
217            if let Some(markers) = env.marker_environment() {
218                if !preference.marker.evaluate(markers, &[]) {
219                    trace!("Excluding {preference} from preferences due to unmatched markers");
220                    continue;
221                }
222
223                if !preference.fork_markers.is_empty() {
224                    if !preference
225                        .fork_markers
226                        .iter()
227                        .any(|marker| marker.evaluate_no_extras(markers))
228                    {
229                        trace!(
230                            "Excluding {preference} from preferences due to unmatched fork markers"
231                        );
232                        continue;
233                    }
234                }
235            }
236
237            // Flatten the list of markers into individual entries.
238            if preference.fork_markers.is_empty() {
239                map.entry(preference.name).or_default().push(Entry {
240                    marker: UniversalMarker::TRUE,
241                    index: preference.index,
242                    pin: Pin {
243                        version: preference.version,
244                        hashes: preference.hashes,
245                    },
246                    source: preference.source,
247                });
248            } else {
249                for fork_marker in preference.fork_markers {
250                    map.entry(preference.name.clone()).or_default().push(Entry {
251                        marker: fork_marker,
252                        index: preference.index.clone(),
253                        pin: Pin {
254                            version: preference.version.clone(),
255                            hashes: preference.hashes.clone(),
256                        },
257                        source: preference.source,
258                    });
259                }
260            }
261        }
262
263        Self(map)
264    }
265
266    /// Insert a preference at the back.
267    pub(crate) fn insert(
268        &mut self,
269        package_name: PackageName,
270        index: Option<IndexUrl>,
271        markers: UniversalMarker,
272        pin: impl Into<Pin>,
273        source: PreferenceSource,
274    ) {
275        self.0.entry(package_name).or_default().push(Entry {
276            marker: markers,
277            index: PreferenceIndex::from(index),
278            pin: pin.into(),
279            source,
280        });
281    }
282
283    /// Returns an iterator over the preferences.
284    pub(crate) fn iter(
285        &self,
286    ) -> impl Iterator<
287        Item = (
288            &PackageName,
289            impl Iterator<Item = (&UniversalMarker, &PreferenceIndex, &Version)>,
290        ),
291    > {
292        self.0.iter().map(|(name, preferences)| {
293            (
294                name,
295                preferences
296                    .iter()
297                    .map(|entry| (&entry.marker, &entry.index, entry.pin.version())),
298            )
299        })
300    }
301
302    /// Return the pinned version for a package, if any.
303    pub(crate) fn get(&self, package_name: &PackageName) -> &[Entry] {
304        self.0
305            .get(package_name)
306            .map(Vec::as_slice)
307            .unwrap_or_default()
308    }
309
310    /// Return the hashes for a package, if the version matches that of the pin.
311    pub(crate) fn match_hashes(
312        &self,
313        package_name: &PackageName,
314        version: &Version,
315    ) -> Option<&[HashDigest]> {
316        self.0
317            .get(package_name)
318            .into_iter()
319            .flatten()
320            .find(|entry| entry.pin.version() == version)
321            .map(|entry| entry.pin.hashes())
322    }
323}
324
325impl std::fmt::Display for Preference {
326    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327        write!(f, "{}=={}", self.name, self.version)
328    }
329}
330
331/// The pinned data associated with a package in a locked `requirements.txt` file (e.g., `flask==1.2.3`).
332#[derive(Debug, Clone)]
333pub(crate) struct Pin {
334    version: Version,
335    hashes: HashDigests,
336}
337
338impl Pin {
339    /// Return the version of the pinned package.
340    pub(crate) fn version(&self) -> &Version {
341        &self.version
342    }
343
344    /// Return the hashes of the pinned package.
345    fn hashes(&self) -> &[HashDigest] {
346        self.hashes.as_slice()
347    }
348}
349
350impl From<Version> for Pin {
351    fn from(version: Version) -> Self {
352        Self {
353            version,
354            hashes: HashDigests::empty(),
355        }
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use std::str::FromStr;
363
364    /// Test that [`PreferenceIndex::matches`] correctly ignores credentials when comparing URLs.
365    ///
366    /// This is relevant for matching lockfile preferences (stored without credentials)
367    /// against index URLs from pyproject.toml (which may include usernames for auth).
368    #[test]
369    fn test_preference_index_matches_ignores_credentials() {
370        // URL without credentials (as stored in lockfile)
371        let index_without_creds = IndexUrl::from_str("https:/pypi_index.com/simple").unwrap();
372
373        // URL with username (as specified in pyproject.toml)
374        let index_with_username =
375            IndexUrl::from_str("https://username@pypi_index.com/simple").unwrap();
376
377        let preference = PreferenceIndex::Explicit(index_without_creds.clone());
378
379        assert!(
380            preference.matches(&index_with_username),
381            "PreferenceIndex should match URLs that differ only in username"
382        );
383    }
384}