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#[derive(Clone, Debug)]
26pub struct Preference {
27 name: PackageName,
28 version: Version,
29 marker: MarkerTree,
31 index: PreferenceIndex,
33 fork_markers: Vec<UniversalMarker>,
36 hashes: HashDigests,
37 source: PreferenceSource,
39}
40
41impl Preference {
42 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 fork_markers: vec![],
70 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 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 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 fork_markers: vec![],
118 hashes: HashDigests::empty(),
119 source: PreferenceSource::Lock,
120 }))
121 }
122
123 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 pub fn name(&self) -> &PackageName {
141 &self.name
142 }
143
144 pub fn version(&self) -> &Version {
146 &self.version
147 }
148}
149
150#[derive(Debug, Clone)]
151pub enum PreferenceIndex {
152 Any,
154 Implicit,
156 Explicit(IndexUrl),
158}
159
160impl PreferenceIndex {
161 pub(crate) fn matches(&self, index: &IndexUrl) -> bool {
163 match self {
164 Self::Any => true,
165 Self::Implicit => false,
166 Self::Explicit(preference) => {
167 *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 Environment,
188 Lock,
190 RequirementsTxt,
192 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 pub(crate) fn marker(&self) -> &UniversalMarker {
207 &self.marker
208 }
209
210 pub(crate) fn index(&self) -> &PreferenceIndex {
212 &self.index
213 }
214
215 pub(crate) fn pin(&self) -> &Pin {
217 &self.pin
218 }
219
220 pub(crate) fn source(&self) -> PreferenceSource {
222 self.source
223 }
224}
225
226#[derive(Debug, Clone, Default)]
233pub struct Preferences(FxHashMap<PackageName, Vec<Entry>>);
234
235impl Preferences {
236 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 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 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 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 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 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 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#[derive(Debug, Clone)]
363pub(crate) struct Pin {
364 version: Version,
365 hashes: HashDigests,
366}
367
368impl Pin {
369 pub(crate) fn version(&self) -> &Version {
371 &self.version
372 }
373
374 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]
399 fn test_preference_index_matches_ignores_credentials() {
400 let index_without_creds = IndexUrl::from_str("https:/pypi_index.com/simple").unwrap();
402
403 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}