uv_resolver/lock/installable.rs
1use std::collections::BTreeSet;
2use std::collections::VecDeque;
3use std::collections::hash_map::Entry;
4use std::path::Path;
5use std::sync::Arc;
6
7use either::Either;
8use itertools::Itertools;
9use petgraph::Graph;
10use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
11
12use uv_configuration::ExtrasSpecificationWithDefaults;
13use uv_configuration::{BuildOptions, DependencyGroupsWithDefaults, InstallOptions};
14use uv_distribution_types::{Edge, Node, Resolution, ResolvedDist};
15use uv_normalize::{ExtraName, GroupName, PackageName};
16use uv_platform_tags::Tags;
17use uv_pypi_types::ResolverMarkerEnvironment;
18
19use crate::lock::{LockErrorKind, Package, TagPolicy};
20use crate::{Lock, LockError};
21
22pub trait Installable<'lock> {
23 /// Return the root install path.
24 fn install_path(&self) -> &'lock Path;
25
26 /// Return the [`Lock`] to install.
27 fn lock(&self) -> &'lock Lock;
28
29 /// Return the [`PackageName`] of the root packages in the target.
30 fn roots(&self) -> impl Iterator<Item = &PackageName>;
31
32 /// Return the [`PackageName`] of the target, if available.
33 fn project_name(&self) -> Option<&PackageName>;
34
35 /// Convert the [`Lock`] to a [`Resolution`] using the given marker environment, tags, and root.
36 fn to_resolution(
37 &self,
38 marker_env: &ResolverMarkerEnvironment,
39 tags: &Tags,
40 extras: &ExtrasSpecificationWithDefaults,
41 groups: &DependencyGroupsWithDefaults,
42 build_options: &BuildOptions,
43 install_options: &InstallOptions,
44 ) -> Result<Resolution, LockError> {
45 let size_guess = self.lock().packages.len();
46 let mut petgraph = Graph::with_capacity(size_guess, size_guess);
47 let mut inverse = FxHashMap::with_capacity_and_hasher(size_guess, FxBuildHasher);
48
49 let mut queue: VecDeque<(&Package, Option<&ExtraName>)> = VecDeque::new();
50 let mut seen = FxHashSet::default();
51 let mut activated_projects: Vec<&PackageName> = vec![];
52 let mut activated_extras: Vec<(&PackageName, &ExtraName)> = vec![];
53 let mut activated_groups: Vec<(&PackageName, &GroupName)> = vec![];
54
55 let root = petgraph.add_node(Node::Root);
56
57 // Determine the set of activated extras and groups, from the root.
58 //
59 // TODO(charlie): This isn't quite right. Below, when we add the dependency groups to the
60 // graph, we rely on the activated extras and dependency groups, to evaluate the conflict
61 // marker. But at that point, we don't know the full set of activated extras; this is only
62 // computed below. We somehow need to add the dependency groups _after_ we've computed all
63 // enabled extras, but the groups themselves could depend on the set of enabled extras.
64 if !self.lock().conflicts().is_empty() {
65 for root_name in self.roots() {
66 let dist = self
67 .lock()
68 .find_by_name(root_name)
69 .map_err(|_| LockErrorKind::MultipleRootPackages {
70 name: root_name.clone(),
71 })?
72 .ok_or_else(|| LockErrorKind::MissingRootPackage {
73 name: root_name.clone(),
74 })?;
75
76 // Track the activated extras.
77 if groups.prod() {
78 activated_projects.push(&dist.id.name);
79 for extra in extras.extra_names(dist.optional_dependencies.keys()) {
80 activated_extras.push((&dist.id.name, extra));
81 }
82 }
83
84 // Track the activated groups.
85 for group in dist
86 .dependency_groups
87 .keys()
88 .filter(|group| groups.contains(group))
89 {
90 activated_groups.push((&dist.id.name, group));
91 }
92 }
93 }
94
95 // Initialize the workspace roots.
96 let mut roots = vec![];
97 for root_name in self.roots() {
98 let dist = self
99 .lock()
100 .find_by_name(root_name)
101 .map_err(|_| LockErrorKind::MultipleRootPackages {
102 name: root_name.clone(),
103 })?
104 .ok_or_else(|| LockErrorKind::MissingRootPackage {
105 name: root_name.clone(),
106 })?;
107
108 // Add the workspace package to the graph.
109 let index = petgraph.add_node(if groups.prod() {
110 self.package_to_node(dist, tags, build_options, install_options, marker_env)?
111 } else {
112 self.non_installable_node(dist, tags, marker_env)?
113 });
114 inverse.insert(&dist.id, index);
115
116 // Add an edge from the root.
117 petgraph.add_edge(root, index, Edge::Prod);
118
119 // Push the package onto the queue.
120 roots.push((dist, index));
121 }
122
123 // Add the workspace dependencies to the queue.
124 for (dist, index) in roots {
125 if groups.prod() {
126 // Push its dependencies onto the queue.
127 queue.push_back((dist, None));
128 for extra in extras.extra_names(dist.optional_dependencies.keys()) {
129 queue.push_back((dist, Some(extra)));
130 }
131 }
132
133 // Add any dev dependencies.
134 for (group, dep) in dist
135 .dependency_groups
136 .iter()
137 .filter_map(|(group, deps)| {
138 if groups.contains(group) {
139 Some(deps.iter().map(move |dep| (group, dep)))
140 } else {
141 None
142 }
143 })
144 .flatten()
145 {
146 if !dep.complexified_marker.evaluate(
147 marker_env,
148 activated_projects.iter().copied(),
149 activated_extras.iter().copied(),
150 activated_groups.iter().copied(),
151 ) {
152 continue;
153 }
154
155 let dep_dist = self.lock().find_by_id(&dep.package_id);
156
157 // Add the package to the graph.
158 let dep_index = match inverse.entry(&dep.package_id) {
159 Entry::Vacant(entry) => {
160 let index = petgraph.add_node(self.package_to_node(
161 dep_dist,
162 tags,
163 build_options,
164 install_options,
165 marker_env,
166 )?);
167 entry.insert(index);
168 index
169 }
170 Entry::Occupied(entry) => {
171 // Critically, if the package is already in the graph, then it's a workspace
172 // member. If it was omitted due to, e.g., `--only-dev`, but is itself
173 // referenced as a development dependency, then we need to re-enable it.
174 let index = *entry.get();
175 let node = &mut petgraph[index];
176 if !groups.prod() {
177 *node = self.package_to_node(
178 dep_dist,
179 tags,
180 build_options,
181 install_options,
182 marker_env,
183 )?;
184 }
185 index
186 }
187 };
188
189 petgraph.add_edge(
190 index,
191 dep_index,
192 // This is OK because we are resolving to a resolution for
193 // a specific marker environment and set of extras/groups.
194 // So at this point, we know the extras/groups have been
195 // satisfied, so we can safely drop the conflict marker.
196 Edge::Dev(group.clone()),
197 );
198
199 // Push its dependencies on the queue.
200 if seen.insert((&dep.package_id, None)) {
201 queue.push_back((dep_dist, None));
202 }
203 for extra in &dep.extra {
204 if seen.insert((&dep.package_id, Some(extra))) {
205 queue.push_back((dep_dist, Some(extra)));
206 }
207 }
208 }
209 }
210
211 // Add any requirements that are exclusive to the workspace root (e.g., dependencies in
212 // PEP 723 scripts).
213 for dependency in self.lock().requirements() {
214 if !dependency.marker.evaluate(marker_env, &[]) {
215 continue;
216 }
217
218 let root_name = &dependency.name;
219 let dist = self
220 .lock()
221 .find_by_markers(root_name, marker_env)
222 .map_err(|_| LockErrorKind::MultipleRootPackages {
223 name: root_name.clone(),
224 })?
225 .ok_or_else(|| LockErrorKind::MissingRootPackage {
226 name: root_name.clone(),
227 })?;
228
229 // Add the package to the graph.
230 let index = petgraph.add_node(if groups.prod() {
231 self.package_to_node(dist, tags, build_options, install_options, marker_env)?
232 } else {
233 self.non_installable_node(dist, tags, marker_env)?
234 });
235 inverse.insert(&dist.id, index);
236
237 // Add the edge.
238 petgraph.add_edge(root, index, Edge::Prod);
239
240 // Push its dependencies on the queue.
241 if seen.insert((&dist.id, None)) {
242 queue.push_back((dist, None));
243 }
244 for extra in &dependency.extras {
245 if seen.insert((&dist.id, Some(extra))) {
246 queue.push_back((dist, Some(extra)));
247 }
248 }
249 }
250
251 // Add any dependency groups that are exclusive to the workspace root (e.g., dev
252 // dependencies in (legacy) non-project workspace roots).
253 for (group, dependency) in self
254 .lock()
255 .dependency_groups()
256 .iter()
257 .filter_map(|(group, deps)| {
258 if groups.contains(group) {
259 Some(deps.iter().map(move |dep| (group, dep)))
260 } else {
261 None
262 }
263 })
264 .flatten()
265 {
266 if !dependency.marker.evaluate(marker_env, &[]) {
267 continue;
268 }
269
270 let root_name = &dependency.name;
271 let dist = self
272 .lock()
273 .find_by_markers(root_name, marker_env)
274 .map_err(|_| LockErrorKind::MultipleRootPackages {
275 name: root_name.clone(),
276 })?
277 .ok_or_else(|| LockErrorKind::MissingRootPackage {
278 name: root_name.clone(),
279 })?;
280
281 // Add the package to the graph.
282 let index = match inverse.entry(&dist.id) {
283 Entry::Vacant(entry) => {
284 let index = petgraph.add_node(self.package_to_node(
285 dist,
286 tags,
287 build_options,
288 install_options,
289 marker_env,
290 )?);
291 entry.insert(index);
292 index
293 }
294 Entry::Occupied(entry) => {
295 // Critically, if the package is already in the graph, then it's a workspace
296 // member. If it was omitted due to, e.g., `--only-dev`, but is itself
297 // referenced as a development dependency, then we need to re-enable it.
298 let index = *entry.get();
299 let node = &mut petgraph[index];
300 if !groups.prod() {
301 *node = self.package_to_node(
302 dist,
303 tags,
304 build_options,
305 install_options,
306 marker_env,
307 )?;
308 }
309 index
310 }
311 };
312
313 // Add the edge.
314 petgraph.add_edge(root, index, Edge::Dev(group.clone()));
315
316 // Push its dependencies on the queue.
317 if seen.insert((&dist.id, None)) {
318 queue.push_back((dist, None));
319 }
320 for extra in &dependency.extras {
321 if seen.insert((&dist.id, Some(extra))) {
322 queue.push_back((dist, Some(extra)));
323 }
324 }
325 }
326
327 // Below, we traverse the dependency graph in a breadth first manner
328 // twice. It's only in the second traversal that we actually build
329 // up our resolution graph. In the first traversal, we accumulate all
330 // activated extras. This includes the extras explicitly enabled on
331 // the CLI (which were gathered above) and the extras enabled via
332 // dependency specifications like `foo[extra]`. We need to do this
333 // to correctly support conflicting extras.
334 //
335 // In particular, the way conflicting extras works is by forking the
336 // resolver based on the extras that are declared as conflicting. But
337 // this forking needs to be made manifest somehow in the lock file to
338 // avoid multiple versions of the same package being installed into the
339 // environment. This is why "conflict markers" were invented. For
340 // example, you might have both `torch` and `torch+cpu` in your
341 // dependency graph, where the latter is only enabled when the `cpu`
342 // extra is enabled, and the former is specifically *not* enabled
343 // when the `cpu` extra is enabled.
344 //
345 // In order to evaluate these conflict markers correctly, we need to
346 // know whether the `cpu` extra is enabled when we visit the `torch`
347 // dependency. If we think it's disabled, then we'll erroneously
348 // include it if the extra is actually enabled. But in order to tell
349 // if it's enabled, we need to traverse the entire dependency graph
350 // first to inspect which extras are enabled!
351 //
352 // Of course, we don't need to do this at all if there aren't any
353 // conflicts. In which case, we skip all of this and just do the one
354 // traversal below.
355 if !self.lock().conflicts().is_empty() {
356 let mut activated_extras_set: BTreeSet<(&PackageName, &ExtraName)> =
357 activated_extras.iter().copied().collect();
358 let mut queue = queue.clone();
359 let mut seen = seen.clone();
360 while let Some((package, extra)) = queue.pop_front() {
361 let deps = if let Some(extra) = extra {
362 Either::Left(
363 package
364 .optional_dependencies
365 .get(extra)
366 .into_iter()
367 .flatten(),
368 )
369 } else {
370 Either::Right(package.dependencies.iter())
371 };
372 for dep in deps {
373 let mut additional_activated_extras = vec![];
374 for extra in &dep.extra {
375 let key = (&dep.package_id.name, extra);
376 if !activated_extras_set.contains(&key) {
377 additional_activated_extras.push(key);
378 }
379 }
380 if !dep.complexified_marker.evaluate(
381 marker_env,
382 activated_projects.iter().copied(),
383 activated_extras
384 .iter()
385 .chain(additional_activated_extras.iter())
386 .copied(),
387 activated_groups.iter().copied(),
388 ) {
389 continue;
390 }
391 // It is, I believe, possible to be here for a dependency that
392 // will ultimately not be included in the final resolution.
393 // Specifically, carrying on from the example in the comments
394 // above, we might visit `torch` first and thus not know if
395 // the `cpu` feature is enabled or not, and thus, the marker
396 // evaluation above will pass.
397 //
398 // So is this a problem? Well, this is the main reason why we
399 // do two graph traversals. On the second traversal below, we
400 // will have seen all of the enabled extras, and so `torch`
401 // will be excluded.
402 //
403 // But could this lead to a bigger list of activated extras
404 // than we actually have? I believe that is indeed possible,
405 // but I think it is only a problem if it leads to extras that
406 // *conflict* with one another being simultaneously enabled.
407 // However, after this first traversal, we check our set of
408 // accumulated extras to ensure that there are no conflicts. If
409 // there are, we raise an error. ---AG
410
411 for key in additional_activated_extras {
412 activated_extras_set.insert(key);
413 activated_extras.push(key);
414 }
415 let dep_dist = self.lock().find_by_id(&dep.package_id);
416 // Push its dependencies on the queue.
417 if seen.insert((&dep.package_id, None)) {
418 queue.push_back((dep_dist, None));
419 }
420 for extra in &dep.extra {
421 if seen.insert((&dep.package_id, Some(extra))) {
422 queue.push_back((dep_dist, Some(extra)));
423 }
424 }
425 }
426 }
427 // At time of writing, it's somewhat expected that the set of
428 // conflicting extras is pretty small. With that said, the
429 // time complexity of the following routine is pretty gross.
430 // Namely, `set.contains` is linear in the size of the set,
431 // iteration over all conflicts is also obviously linear in
432 // the number of conflicting sets and then for each of those,
433 // we visit every possible pair of activated extra from above,
434 // which is quadratic in the total number of extras enabled. I
435 // believe the simplest improvement here, if it's necessary, is
436 // to adjust the `Conflicts` internals to own these sorts of
437 // checks. ---AG
438 for set in self.lock().conflicts().iter() {
439 for ((pkg1, extra1), (pkg2, extra2)) in
440 activated_extras_set.iter().tuple_combinations()
441 {
442 if set.contains(pkg1, *extra1) && set.contains(pkg2, *extra2) {
443 return Err(LockErrorKind::ConflictingExtra {
444 package1: (*pkg1).clone(),
445 extra1: (*extra1).clone(),
446 package2: (*pkg2).clone(),
447 extra2: (*extra2).clone(),
448 }
449 .into());
450 }
451 }
452 }
453 }
454
455 while let Some((package, extra)) = queue.pop_front() {
456 let deps = if let Some(extra) = extra {
457 Either::Left(
458 package
459 .optional_dependencies
460 .get(extra)
461 .into_iter()
462 .flatten(),
463 )
464 } else {
465 Either::Right(package.dependencies.iter())
466 };
467 for dep in deps {
468 if !dep.complexified_marker.evaluate(
469 marker_env,
470 activated_projects.iter().copied(),
471 activated_extras.iter().copied(),
472 activated_groups.iter().copied(),
473 ) {
474 continue;
475 }
476
477 let dep_dist = self.lock().find_by_id(&dep.package_id);
478
479 // Add the dependency to the graph.
480 let dep_index = match inverse.entry(&dep.package_id) {
481 Entry::Vacant(entry) => {
482 let index = petgraph.add_node(self.package_to_node(
483 dep_dist,
484 tags,
485 build_options,
486 install_options,
487 marker_env,
488 )?);
489 entry.insert(index);
490 index
491 }
492 Entry::Occupied(entry) => *entry.get(),
493 };
494
495 // Add the edge.
496 let index = inverse[&package.id];
497 petgraph.add_edge(
498 index,
499 dep_index,
500 if let Some(extra) = extra {
501 Edge::Optional(extra.clone())
502 } else {
503 Edge::Prod
504 },
505 );
506
507 // Push its dependencies on the queue.
508 if seen.insert((&dep.package_id, None)) {
509 queue.push_back((dep_dist, None));
510 }
511 for extra in &dep.extra {
512 if seen.insert((&dep.package_id, Some(extra))) {
513 queue.push_back((dep_dist, Some(extra)));
514 }
515 }
516 }
517 }
518
519 Ok(Resolution::new(petgraph))
520 }
521
522 /// Create an installable [`Node`] from a [`Package`].
523 fn installable_node(
524 &self,
525 package: &Package,
526 tags: &Tags,
527 marker_env: &ResolverMarkerEnvironment,
528 build_options: &BuildOptions,
529 ) -> Result<Node, LockError> {
530 let dist = package.to_dist(
531 self.install_path(),
532 TagPolicy::Required(tags),
533 build_options,
534 marker_env,
535 )?;
536 let version = package.version().cloned();
537 let dist = ResolvedDist::Installable {
538 dist: Arc::new(dist),
539 version,
540 };
541 let hashes = package.hashes();
542 Ok(Node::Dist {
543 dist,
544 hashes,
545 install: true,
546 })
547 }
548
549 /// Create a non-installable [`Node`] from a [`Package`].
550 fn non_installable_node(
551 &self,
552 package: &Package,
553 tags: &Tags,
554 marker_env: &ResolverMarkerEnvironment,
555 ) -> Result<Node, LockError> {
556 let dist = package.to_dist(
557 self.install_path(),
558 TagPolicy::Preferred(tags),
559 &BuildOptions::default(),
560 marker_env,
561 )?;
562 let version = package.version().cloned();
563 let dist = ResolvedDist::Installable {
564 dist: Arc::new(dist),
565 version,
566 };
567 let hashes = package.hashes();
568 Ok(Node::Dist {
569 dist,
570 hashes,
571 install: false,
572 })
573 }
574
575 /// Convert a lockfile entry to a graph [`Node`].
576 fn package_to_node(
577 &self,
578 package: &Package,
579 tags: &Tags,
580 build_options: &BuildOptions,
581 install_options: &InstallOptions,
582 marker_env: &ResolverMarkerEnvironment,
583 ) -> Result<Node, LockError> {
584 if install_options.include_package(
585 package.as_install_target(),
586 self.project_name(),
587 self.lock().members(),
588 ) {
589 self.installable_node(package, tags, marker_env, build_options)
590 } else {
591 self.non_installable_node(package, tags, marker_env)
592 }
593 }
594}