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
145#[derive(Debug, Clone)]
146pub(crate) enum PreferenceIndex {
147 Any,
149 Implicit,
151 Explicit(IndexUrl),
153}
154
155impl PreferenceIndex {
156 pub(crate) fn matches(&self, index: &IndexUrl) -> bool {
158 match self {
159 Self::Any => true,
160 Self::Implicit => false,
161 Self::Explicit(preference) => {
162 *preference.url() == *index.without_credentials()
165 }
166 }
167 }
168}
169
170impl From<Option<IndexUrl>> for PreferenceIndex {
171 fn from(index: Option<IndexUrl>) -> Self {
172 match index {
173 Some(index) => Self::Explicit(index),
174 None => Self::Implicit,
175 }
176 }
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub(crate) enum PreferenceSource {
181 Environment,
183 Lock,
185 RequirementsTxt,
187 Resolver,
189}
190
191#[derive(Debug, Clone)]
192pub(crate) struct Entry {
193 marker: UniversalMarker,
194 index: PreferenceIndex,
195 pin: Pin,
196 source: PreferenceSource,
197}
198
199impl Entry {
200 pub(crate) fn marker(&self) -> &UniversalMarker {
202 &self.marker
203 }
204
205 pub(crate) fn index(&self) -> &PreferenceIndex {
207 &self.index
208 }
209
210 pub(crate) fn pin(&self) -> &Pin {
212 &self.pin
213 }
214
215 pub(crate) fn source(&self) -> PreferenceSource {
217 self.source
218 }
219}
220
221#[derive(Debug, Clone, Default)]
228pub struct Preferences(FxHashMap<PackageName, Vec<Entry>>);
229
230impl Preferences {
231 pub fn from_iter(
236 preferences: impl IntoIterator<Item = Preference>,
237 env: &ResolverEnvironment,
238 ) -> Self {
239 let mut map = FxHashMap::<PackageName, Vec<_>>::default();
240 for preference in preferences {
241 if let Some(markers) = env.marker_environment() {
243 if !preference.marker.evaluate(markers, &[]) {
244 trace!("Excluding {preference} from preferences due to unmatched markers");
245 continue;
246 }
247
248 if !preference.fork_markers.is_empty() {
249 if !preference
250 .fork_markers
251 .iter()
252 .any(|marker| marker.evaluate_no_extras(markers))
253 {
254 trace!(
255 "Excluding {preference} from preferences due to unmatched fork markers"
256 );
257 continue;
258 }
259 }
260 }
261
262 if preference.fork_markers.is_empty() {
264 map.entry(preference.name).or_default().push(Entry {
265 marker: UniversalMarker::TRUE,
266 index: preference.index,
267 pin: Pin {
268 version: preference.version,
269 hashes: preference.hashes,
270 },
271 source: preference.source,
272 });
273 } else {
274 for fork_marker in preference.fork_markers {
275 map.entry(preference.name.clone()).or_default().push(Entry {
276 marker: fork_marker,
277 index: preference.index.clone(),
278 pin: Pin {
279 version: preference.version.clone(),
280 hashes: preference.hashes.clone(),
281 },
282 source: preference.source,
283 });
284 }
285 }
286 }
287
288 Self(map)
289 }
290
291 pub(crate) fn insert(
293 &mut self,
294 package_name: PackageName,
295 index: Option<IndexUrl>,
296 markers: UniversalMarker,
297 pin: impl Into<Pin>,
298 source: PreferenceSource,
299 ) {
300 self.0.entry(package_name).or_default().push(Entry {
301 marker: markers,
302 index: PreferenceIndex::from(index),
303 pin: pin.into(),
304 source,
305 });
306 }
307
308 pub(crate) fn iter(
310 &self,
311 ) -> impl Iterator<
312 Item = (
313 &PackageName,
314 impl Iterator<Item = (&UniversalMarker, &PreferenceIndex, &Version)>,
315 ),
316 > {
317 self.0.iter().map(|(name, preferences)| {
318 (
319 name,
320 preferences
321 .iter()
322 .map(|entry| (&entry.marker, &entry.index, entry.pin.version())),
323 )
324 })
325 }
326
327 pub(crate) fn get(&self, package_name: &PackageName) -> &[Entry] {
329 self.0
330 .get(package_name)
331 .map(Vec::as_slice)
332 .unwrap_or_default()
333 }
334
335 pub(crate) fn match_hashes(
337 &self,
338 package_name: &PackageName,
339 version: &Version,
340 ) -> Option<&[HashDigest]> {
341 self.0
342 .get(package_name)
343 .into_iter()
344 .flatten()
345 .find(|entry| entry.pin.version() == version)
346 .map(|entry| entry.pin.hashes())
347 }
348}
349
350impl std::fmt::Display for Preference {
351 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352 write!(f, "{}=={}", self.name, self.version)
353 }
354}
355
356#[derive(Debug, Clone)]
358pub(crate) struct Pin {
359 version: Version,
360 hashes: HashDigests,
361}
362
363impl Pin {
364 pub(crate) fn version(&self) -> &Version {
366 &self.version
367 }
368
369 fn hashes(&self) -> &[HashDigest] {
371 self.hashes.as_slice()
372 }
373}
374
375impl From<Version> for Pin {
376 fn from(version: Version) -> Self {
377 Self {
378 version,
379 hashes: HashDigests::empty(),
380 }
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use std::str::FromStr;
388
389 #[test]
394 fn test_preference_index_matches_ignores_credentials() {
395 let index_without_creds = IndexUrl::from_str("https:/pypi_index.com/simple").unwrap();
397
398 let index_with_username =
400 IndexUrl::from_str("https://username@pypi_index.com/simple").unwrap();
401
402 let preference = PreferenceIndex::Explicit(index_without_creds.clone());
403
404 assert!(
405 preference.matches(&index_with_username),
406 "PreferenceIndex should match URLs that differ only in username"
407 );
408 }
409}