Skip to main content

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}