uv_resolver/
preferences.rs

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