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