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::{FxHashMap, FxHashSet};
11
12use uv_configuration::{
13    BuildOptions, DependencyGroupsWithDefaults, ExtrasSpecification,
14    ExtrasSpecificationWithDefaults, InstallOptions,
15};
16use uv_distribution_types::{Edge, FirstParty, Node, Resolution, ResolvedDist};
17use uv_normalize::{DefaultExtras, ExtraName, GroupName, PackageName};
18use uv_platform_tags::Tags;
19use uv_pypi_types::{ConflictKind, ConflictSet, ResolverMarkerEnvironment};
20
21use crate::lock::{
22    Dependency, DependencySelectionContext, HashedDist, LockErrorKind, Package, PackageIndex,
23    SelectedDependency, TagPolicy,
24};
25use crate::universal_marker::ActivatedConflictItems;
26use crate::{Lock, LockError, UniversalMarker};
27
28fn newly_activated_extras<'lock>(
29    dep: &'lock Dependency,
30    activated_extras: &[(&'lock PackageName, &'lock ExtraName)],
31) -> Vec<(&'lock PackageName, &'lock ExtraName)> {
32    dep.extra
33        .iter()
34        .filter_map(|extra| {
35            let key = (&dep.package_id.name, extra);
36            (!activated_extras.contains(&key)).then_some(key)
37        })
38        .collect()
39}
40
41/// Record another condition under which a locked package and optional extra are reachable.
42///
43/// Returns `true` when the combined reachability changed.
44fn add_reachability<'lock>(
45    reachability: &mut FxHashMap<(PackageIndex, Option<&'lock ExtraName>), UniversalMarker>,
46    key: (PackageIndex, Option<&'lock ExtraName>),
47    marker: UniversalMarker,
48) -> bool {
49    match reachability.entry(key) {
50        Entry::Occupied(mut entry) => {
51            let mut combined = *entry.get();
52            combined.or(marker);
53            if combined == *entry.get() {
54                false
55            } else {
56                entry.insert(combined);
57                true
58            }
59        }
60        Entry::Vacant(entry) => {
61            entry.insert(marker);
62            true
63        }
64    }
65}
66
67/// Returns the dependencies a queued package contributes, either its own or those of one extra.
68fn package_dependencies<'a>(
69    package: &'a Package,
70    extra: Option<&ExtraName>,
71) -> impl Iterator<Item = &'a Dependency> {
72    if let Some(extra) = extra {
73        Either::Left(
74            package
75                .optional_dependencies
76                .get(extra)
77                .into_iter()
78                .flatten(),
79        )
80    } else {
81        Either::Right(package.dependencies.iter())
82    }
83}
84
85/// Determines which dependencies are included from an install target root.
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87pub enum InstallableRootKind {
88    /// Include the root's production dependencies and selected dependency groups.
89    Production,
90    /// Include only the root's selected dependency groups.
91    DependencyGroups,
92}
93
94pub trait Installable<'lock> {
95    /// Return the root install path.
96    fn install_path(&self) -> &'lock Path;
97
98    /// Return the [`Lock`] to install.
99    fn lock(&self) -> &'lock Lock;
100
101    /// Return the [`PackageName`] of the root packages in the target.
102    fn roots(&self) -> impl Iterator<Item = &PackageName>;
103
104    /// Return the package whose dependency groups, but not production dependencies, are included.
105    fn group_root(&self, _groups: &DependencyGroupsWithDefaults) -> Option<&PackageName> {
106        None
107    }
108
109    /// Return whether a dependency group should be included for its owning package.
110    ///
111    /// A `None` package represents groups defined directly on a non-project workspace root.
112    fn includes_group(
113        &self,
114        _package: Option<&PackageName>,
115        group: &GroupName,
116        groups: &DependencyGroupsWithDefaults,
117    ) -> bool {
118        groups.contains(group)
119    }
120
121    /// Return the [`PackageName`] of the target, if available.
122    fn project_name(&self) -> Option<&PackageName>;
123
124    /// Convert the [`Lock`] to a [`Resolution`] using the given marker environment, tags, and root.
125    fn to_resolution(
126        &self,
127        marker_env: &ResolverMarkerEnvironment,
128        tags: &Tags,
129        extras: &ExtrasSpecificationWithDefaults,
130        groups: &DependencyGroupsWithDefaults,
131        build_options: &BuildOptions,
132        install_options: &InstallOptions,
133    ) -> Result<Resolution, LockError> {
134        let resolve_root = |root_name: &PackageName| {
135            self.lock()
136                .find_by_name(root_name)
137                .map_err(|_| LockErrorKind::MultipleRootPackages {
138                    name: root_name.clone(),
139                })?
140                .ok_or_else(|| {
141                    LockError::from(LockErrorKind::MissingRootPackage {
142                        name: root_name.clone(),
143                    })
144                })
145        };
146        let roots = self
147            .roots()
148            .map(&resolve_root)
149            .collect::<Result<Vec<_>, LockError>>()?;
150        let group_root = self.group_root(groups).map(resolve_root).transpose()?;
151
152        InstallableExt::to_resolution_from_packages(
153            self,
154            &roots,
155            group_root,
156            true,
157            DependencySelectionContext::None,
158            marker_env,
159            tags,
160            extras,
161            groups,
162            build_options,
163            install_options,
164        )
165    }
166
167    /// Create an installable [`Node`] from a [`Package`].
168    fn installable_node(
169        &self,
170        package: &Package,
171        tags: &Tags,
172        marker_env: &ResolverMarkerEnvironment,
173        build_options: &BuildOptions,
174    ) -> Result<Node, LockError> {
175        let tag_policy = TagPolicy::Required(tags);
176        let HashedDist { dist, hashes } = package.to_dist(
177            self.install_path(),
178            tag_policy,
179            build_options,
180            marker_env,
181            if self.lock().is_workspace_member(package) {
182                FirstParty::Yes
183            } else {
184                FirstParty::No
185            },
186        )?;
187        let version = package.version().cloned();
188        let dist = ResolvedDist::Installable {
189            dist: Arc::new(dist),
190            version,
191        };
192        Ok(Node::Dist {
193            dist,
194            hashes,
195            install: true,
196        })
197    }
198
199    /// Create a non-installable [`Node`] from a [`Package`].
200    fn non_installable_node(
201        &self,
202        package: &Package,
203        tags: &Tags,
204        marker_env: &ResolverMarkerEnvironment,
205    ) -> Result<Node, LockError> {
206        let HashedDist { dist, .. } = package.to_dist(
207            self.install_path(),
208            TagPolicy::Preferred(tags),
209            &BuildOptions::default(),
210            marker_env,
211            FirstParty::No,
212        )?;
213        let version = package.version().cloned();
214        let dist = ResolvedDist::Installable {
215            dist: Arc::new(dist),
216            version,
217        };
218        let hashes = package.hashes();
219        Ok(Node::Dist {
220            dist,
221            hashes,
222            install: false,
223        })
224    }
225
226    /// Convert a lockfile entry to a graph [`Node`].
227    fn package_to_node(
228        &self,
229        package: &Package,
230        tags: &Tags,
231        build_options: &BuildOptions,
232        install_options: &InstallOptions,
233        marker_env: &ResolverMarkerEnvironment,
234    ) -> Result<Node, LockError> {
235        if install_options.include_package(
236            package.as_install_target(),
237            self.project_name(),
238            self.lock().members(),
239        ) {
240            self.installable_node(package, tags, marker_env, build_options)
241        } else {
242            self.non_installable_node(package, tags, marker_env)
243        }
244    }
245}
246
247/// Internal lock-to-resolution implementation shared by [`Installable`] and [`Lock`].
248trait InstallableExt<'lock>: Installable<'lock> {
249    /// Convert concrete locked packages to a [`Resolution`].
250    ///
251    /// `include_manifest` controls whether requirements attached directly to the lock target are
252    /// included in addition to `roots`.
253    fn to_resolution_from_packages(
254        &self,
255        roots: &[&Package],
256        group_root: Option<&Package>,
257        include_manifest: bool,
258        selection_context: DependencySelectionContext<'lock>,
259        marker_env: &ResolverMarkerEnvironment,
260        tags: &Tags,
261        extras: &ExtrasSpecificationWithDefaults,
262        groups: &DependencyGroupsWithDefaults,
263        build_options: &BuildOptions,
264        install_options: &InstallOptions,
265    ) -> Result<Resolution, LockError> {
266        let size_guess = self.lock().packages.len();
267        let mut petgraph = Graph::with_capacity(size_guess, size_guess);
268        let mut inverse = vec![None; size_guess];
269
270        let mut queue: VecDeque<(PackageIndex, Option<&ExtraName>)> = VecDeque::new();
271        let mut seen = FxHashSet::default();
272        let mut conflict_reachability = FxHashMap::default();
273        let mut activated_projects: Vec<&PackageName> = vec![];
274        let mut activated_extras: Vec<(&PackageName, &ExtraName)> = vec![];
275        let mut activated_groups: Vec<(&PackageName, &GroupName)> = vec![];
276        let has_conflicts = !self.lock().conflicts().is_empty();
277        let validate_conflicts = !include_manifest && has_conflicts;
278        let mut dependencies_for_conflict_validation = vec![];
279
280        let root = petgraph.add_node(Node::Root);
281
282        match selection_context {
283            DependencySelectionContext::None => {}
284            DependencySelectionContext::Production(project) => {
285                activated_projects.push(project);
286            }
287            DependencySelectionContext::Group(project, group) => {
288                activated_groups.push((project, group));
289            }
290        }
291
292        // Determine the set of activated extras and groups, from the root.
293        //
294        // Extras activated by dependency groups (via `pkg[extra]` entries in the group) are
295        // accumulated below, when we process the groups themselves. This ensures that when we
296        // later evaluate conflict markers on transitive dependencies, self-extras enabled by an
297        // active group are treated as enabled.
298        //
299        // TODO(zanieb): For completeness, the group-dep loop below still has two structural
300        // soundness gaps. Neither is reachable through lockfiles the resolver currently
301        // produces — they'd require a group-dep entry with a strict positive conflict marker
302        // referencing an extra *other* than the entry's own self-extra, and the resolver
303        // either emits self-extra markers (handled by `newly_activated_extras` below) or
304        // markers with vacuously-true disjuncts. But the code should still handle them:
305        //
306        // 1. Ordering: an earlier group-dep entry whose marker references an extra activated
307        //    by a later entry is evaluated with an incomplete `activated_extras` set.
308        // 2. Transitive self-extras: a group dep `pkg[a]` where `pkg.optional_dependencies.a`
309        //    includes `pkg[b]` only activates `(pkg, b)` during the first-pass traversal
310        //    below, so any group dep whose marker needs `(pkg, b)` is evaluated too early.
311        //
312        // Fixing these correctly likely means iterating group-dep activation to a fixed point
313        // or interleaving it with the first-pass traversal.
314        if has_conflicts {
315            for dist in roots.iter().copied() {
316                // Track the activated extras.
317                if groups.prod() {
318                    activated_projects.push(&dist.id.name);
319                    for extra in extras.extra_names(dist.optional_dependencies.keys()) {
320                        activated_extras.push((&dist.id.name, extra));
321                    }
322                }
323            }
324
325            for dist in roots.iter().copied().chain(group_root) {
326                for group in dist
327                    .dependency_groups
328                    .keys()
329                    .filter(|group| self.includes_group(Some(&dist.id.name), group, groups))
330                {
331                    activated_groups.push((&dist.id.name, group));
332                }
333            }
334        }
335
336        // Initialize the workspace roots.
337        let mut initialized_roots = vec![];
338        for (dist, root_kind) in roots
339            .iter()
340            .copied()
341            .map(|dist| (dist, InstallableRootKind::Production))
342            .chain(group_root.map(|dist| (dist, InstallableRootKind::DependencyGroups)))
343        {
344            // Add the workspace package to the graph.
345            let package_index = self.lock().by_id[&dist.id];
346            let index = petgraph.add_node(
347                if root_kind == InstallableRootKind::Production && groups.prod() {
348                    self.package_to_node(dist, tags, build_options, install_options, marker_env)?
349                } else {
350                    self.non_installable_node(dist, tags, marker_env)?
351                },
352            );
353            inverse[package_index.0] = Some(index);
354
355            // Add an edge from the root.
356            petgraph.add_edge(root, index, Edge::Prod);
357
358            // Push the package onto the queue.
359            initialized_roots.push((dist, package_index, index, root_kind));
360        }
361
362        // Add the workspace dependencies to the queue.
363        for (dist, package_index, index, root_kind) in initialized_roots {
364            if root_kind == InstallableRootKind::Production && groups.prod() {
365                // Push its dependencies onto the queue.
366                queue.push_back((package_index, None));
367                add_reachability(
368                    &mut conflict_reachability,
369                    (package_index, None),
370                    UniversalMarker::TRUE,
371                );
372                for extra in extras.extra_names(dist.optional_dependencies.keys()) {
373                    queue.push_back((package_index, Some(extra)));
374                    add_reachability(
375                        &mut conflict_reachability,
376                        (package_index, Some(extra)),
377                        UniversalMarker::TRUE,
378                    );
379                }
380            }
381
382            // Add any dev dependencies.
383            for (group, dep) in dist
384                .dependency_groups
385                .iter()
386                .filter_map(|(group, deps)| {
387                    if self.includes_group(Some(&dist.id.name), group, groups) {
388                        Some(deps.iter().map(move |dep| (group, dep)))
389                    } else {
390                        None
391                    }
392                })
393                .flatten()
394            {
395                if validate_conflicts && dep.complexified_marker.has_conflict_marker() {
396                    dependencies_for_conflict_validation.push((dist, dep));
397                }
398                let additional_activated_extras = newly_activated_extras(dep, &activated_extras);
399                if !dep.complexified_marker.evaluate(
400                    marker_env,
401                    activated_projects.iter().copied(),
402                    activated_extras
403                        .iter()
404                        .chain(additional_activated_extras.iter())
405                        .copied(),
406                    activated_groups.iter().copied(),
407                ) {
408                    continue;
409                }
410
411                let dep_dist = self.lock().package(dep.index);
412
413                // Add the package to the graph.
414                let dep_index = match inverse[dep.index.0] {
415                    None => {
416                        let index = petgraph.add_node(self.package_to_node(
417                            dep_dist,
418                            tags,
419                            build_options,
420                            install_options,
421                            marker_env,
422                        )?);
423                        inverse[dep.index.0] = Some(index);
424                        index
425                    }
426                    Some(index) => {
427                        // Critically, if the package is already in the graph, then it's a workspace
428                        // member. If it was omitted due to, e.g., `--only-dev`, but is itself
429                        // referenced as a development dependency, then we need to re-enable it.
430                        let node = &mut petgraph[index];
431                        if !groups.prod() || matches!(node, Node::Dist { install: false, .. }) {
432                            *node = self.package_to_node(
433                                dep_dist,
434                                tags,
435                                build_options,
436                                install_options,
437                                marker_env,
438                            )?;
439                        }
440                        index
441                    }
442                };
443
444                petgraph.add_edge(
445                    index,
446                    dep_index,
447                    // This is OK because we are resolving to a resolution for
448                    // a specific marker environment and set of extras/groups.
449                    // So at this point, we know the extras/groups have been
450                    // satisfied, so we can safely drop the conflict marker.
451                    Edge::Dev(group.clone()),
452                );
453
454                // Persist any self-extras activated by this group dependency (e.g., a group
455                // that references `pkg[extra]`). Without this, conflict markers on transitive
456                // dependencies gated by the activated extra would not evaluate to `true`
457                // during the graph traversals below.
458                for key in additional_activated_extras {
459                    activated_extras.push(key);
460                }
461
462                // Push its dependencies on the queue.
463                add_reachability(
464                    &mut conflict_reachability,
465                    (dep.index, None),
466                    dep.complexified_marker,
467                );
468                if seen.insert((dep.index, None)) {
469                    queue.push_back((dep.index, None));
470                }
471                for extra in &dep.extra {
472                    add_reachability(
473                        &mut conflict_reachability,
474                        (dep.index, Some(extra)),
475                        dep.complexified_marker,
476                    );
477                    if seen.insert((dep.index, Some(extra))) {
478                        queue.push_back((dep.index, Some(extra)));
479                    }
480                }
481            }
482        }
483
484        if include_manifest {
485            // Add any requirements that are exclusive to the workspace root (e.g., dependencies in
486            // PEP 723 scripts).
487            for dependency in self.lock().requirements() {
488                if !dependency.marker.evaluate(marker_env, &[]) {
489                    continue;
490                }
491
492                let root_name = &dependency.name;
493                let dist = self
494                    .lock()
495                    .find_by_markers(root_name, marker_env)
496                    .map_err(|_| LockErrorKind::MultipleRootPackages {
497                        name: root_name.clone(),
498                    })?
499                    .ok_or_else(|| LockErrorKind::MissingRootPackage {
500                        name: root_name.clone(),
501                    })?;
502
503                // Add the package to the graph.
504                let package_index = self.lock().by_id[&dist.id];
505                let index = petgraph.add_node(if groups.prod() {
506                    self.package_to_node(dist, tags, build_options, install_options, marker_env)?
507                } else {
508                    self.non_installable_node(dist, tags, marker_env)?
509                });
510                inverse[package_index.0] = Some(index);
511
512                // Add the edge.
513                petgraph.add_edge(root, index, Edge::Prod);
514
515                // Push its dependencies on the queue.
516                add_reachability(
517                    &mut conflict_reachability,
518                    (package_index, None),
519                    UniversalMarker::TRUE,
520                );
521                if seen.insert((package_index, None)) {
522                    queue.push_back((package_index, None));
523                }
524                for extra in &dependency.extras {
525                    add_reachability(
526                        &mut conflict_reachability,
527                        (package_index, Some(extra)),
528                        UniversalMarker::TRUE,
529                    );
530                    if seen.insert((package_index, Some(extra))) {
531                        queue.push_back((package_index, Some(extra)));
532                    }
533                }
534            }
535
536            // Add any dependency groups that are exclusive to the workspace root (e.g., dev
537            // dependencies in non-project workspace roots).
538            for (group, dependency) in self
539                .lock()
540                .dependency_groups()
541                .iter()
542                .filter_map(|(group, deps)| {
543                    if self.includes_group(None, group, groups) {
544                        Some(deps.iter().map(move |dep| (group, dep)))
545                    } else {
546                        None
547                    }
548                })
549                .flatten()
550            {
551                if !dependency.marker.evaluate(marker_env, &[]) {
552                    continue;
553                }
554
555                let root_name = &dependency.name;
556                let dist = self
557                    .lock()
558                    .find_by_markers(root_name, marker_env)
559                    .map_err(|_| LockErrorKind::MultipleRootPackages {
560                        name: root_name.clone(),
561                    })?
562                    .ok_or_else(|| LockErrorKind::MissingRootPackage {
563                        name: root_name.clone(),
564                    })?;
565
566                // Add the package to the graph.
567                let package_index = self.lock().by_id[&dist.id];
568                let index = match inverse[package_index.0] {
569                    None => {
570                        let index = petgraph.add_node(self.package_to_node(
571                            dist,
572                            tags,
573                            build_options,
574                            install_options,
575                            marker_env,
576                        )?);
577                        inverse[package_index.0] = Some(index);
578                        index
579                    }
580                    Some(index) => {
581                        // Critically, if the package is already in the graph, then it's a workspace
582                        // member. If it was omitted due to, e.g., `--only-dev`, but is itself
583                        // referenced as a development dependency, then we need to re-enable it.
584                        let node = &mut petgraph[index];
585                        if !groups.prod() {
586                            *node = self.package_to_node(
587                                dist,
588                                tags,
589                                build_options,
590                                install_options,
591                                marker_env,
592                            )?;
593                        }
594                        index
595                    }
596                };
597
598                // Add the edge.
599                petgraph.add_edge(root, index, Edge::Dev(group.clone()));
600
601                // Persist any self-extras activated by this group dependency. Mirrors the
602                // handling in the package-level `dependency_groups` loop above; without this,
603                // conflict markers on transitive dependencies gated by the activated extra
604                // would not evaluate to `true` during the graph traversals below.
605                for extra in &dependency.extras {
606                    let key = (&dist.id.name, extra);
607                    if !activated_extras.contains(&key) {
608                        activated_extras.push(key);
609                    }
610                }
611
612                // Push its dependencies on the queue.
613                add_reachability(
614                    &mut conflict_reachability,
615                    (package_index, None),
616                    UniversalMarker::TRUE,
617                );
618                if seen.insert((package_index, None)) {
619                    queue.push_back((package_index, None));
620                }
621                for extra in &dependency.extras {
622                    add_reachability(
623                        &mut conflict_reachability,
624                        (package_index, Some(extra)),
625                        UniversalMarker::TRUE,
626                    );
627                    if seen.insert((package_index, Some(extra))) {
628                        queue.push_back((package_index, Some(extra)));
629                    }
630                }
631            }
632        }
633
634        // Below, we traverse the dependency graph in a breadth first manner
635        // twice. It's only in the second traversal that we actually build
636        // up our resolution graph. In the first traversal, we accumulate all
637        // activated extras. This includes the extras explicitly enabled on
638        // the CLI (which were gathered above) and the extras enabled via
639        // dependency specifications like `foo[extra]`. We need to do this
640        // to correctly support conflicting extras.
641        //
642        // In particular, the way conflicting extras works is by forking the
643        // resolver based on the extras that are declared as conflicting. But
644        // this forking needs to be made manifest somehow in the lock file to
645        // avoid multiple versions of the same package being installed into the
646        // environment. This is why "conflict markers" were invented. For
647        // example, you might have both `torch` and `torch+cpu` in your
648        // dependency graph, where the latter is only enabled when the `cpu`
649        // extra is enabled, and the former is specifically *not* enabled
650        // when the `cpu` extra is enabled.
651        //
652        // In order to evaluate these conflict markers correctly, we need to
653        // know whether the `cpu` extra is enabled when we visit the `torch`
654        // dependency. If we think it's disabled, then we'll erroneously
655        // include it if the extra is actually enabled. But in order to tell
656        // if it's enabled, we need to traverse the entire dependency graph
657        // first to inspect which extras are enabled!
658        //
659        // Of course, we don't need to do this at all if there aren't any
660        // conflicts. In which case, we skip all of this and just do the one
661        // traversal below.
662        if has_conflicts {
663            let mut activated_extras_set: BTreeSet<(&PackageName, &ExtraName)> =
664                activated_extras.iter().copied().collect();
665            let mut queue = queue.clone();
666            let mut reachability = conflict_reachability;
667            while let Some((package_index, extra)) = queue.pop_front() {
668                let package = self.lock().package(package_index);
669                let Some(parent_reachability) = reachability.get(&(package_index, extra)).copied()
670                else {
671                    continue;
672                };
673                for dep in package_dependencies(package, extra) {
674                    let mut dep_reachability = dep.complexified_marker;
675                    dep_reachability.and(parent_reachability);
676                    let additional_activated_extras =
677                        newly_activated_extras(dep, &activated_extras);
678                    if !dep_reachability.evaluate(
679                        marker_env,
680                        activated_projects.iter().copied(),
681                        activated_extras
682                            .iter()
683                            .chain(additional_activated_extras.iter())
684                            .copied(),
685                        activated_groups.iter().copied(),
686                    ) {
687                        continue;
688                    }
689                    // The dependency can still be visited provisionally before all activated
690                    // extras are known. The second traversal below will exclude it once those
691                    // extras are available. Crucially, `dep_reachability` includes the conditions
692                    // required to reach the parent package: dependency markers may have been
693                    // simplified under those conditions and cannot stand alone during this
694                    // preliminary traversal. Otherwise, an unreachable package could activate an
695                    // extra and cause the conflict check below to report a false positive.
696
697                    for key in additional_activated_extras {
698                        activated_extras_set.insert(key);
699                        activated_extras.push(key);
700                    }
701                    // Push its dependencies on the queue.
702                    if add_reachability(&mut reachability, (dep.index, None), dep_reachability) {
703                        queue.push_back((dep.index, None));
704                    }
705                    for extra in &dep.extra {
706                        if add_reachability(
707                            &mut reachability,
708                            (dep.index, Some(extra)),
709                            dep_reachability,
710                        ) {
711                            queue.push_back((dep.index, Some(extra)));
712                        }
713                    }
714                }
715            }
716            // At time of writing, it's somewhat expected that the set of
717            // conflicting extras is pretty small. With that said, the
718            // time complexity of the following routine is pretty gross.
719            // Namely, `set.contains` is linear in the size of the set,
720            // iteration over all conflicts is also obviously linear in
721            // the number of conflicting sets and then for each of those,
722            // we visit every possible pair of activated extra from above,
723            // which is quadratic in the total number of extras enabled. I
724            // believe the simplest improvement here, if it's necessary, is
725            // to adjust the `Conflicts` internals to own these sorts of
726            // checks. ---AG
727            for set in self.lock().conflicts().iter() {
728                for ((pkg1, extra1), (pkg2, extra2)) in
729                    activated_extras_set.iter().tuple_combinations()
730                {
731                    if set.contains(pkg1, *extra1) && set.contains(pkg2, *extra2) {
732                        return Err(LockErrorKind::ConflictingExtra {
733                            package1: (*pkg1).clone(),
734                            extra1: (*extra1).clone(),
735                            package2: (*pkg2).clone(),
736                            extra2: (*extra2).clone(),
737                        }
738                        .into());
739                    }
740                }
741            }
742        }
743
744        // Unlike the traversals above, this one never activates an extra, so the activated set is
745        // fixed for its duration and can be encoded once instead of once per dependency.
746        let activated = ActivatedConflictItems::new(
747            activated_projects.iter().copied(),
748            activated_extras.iter().copied(),
749            activated_groups.iter().copied(),
750        );
751
752        while let Some((package_index, extra)) = queue.pop_front() {
753            let package = self.lock().package(package_index);
754            for dep in package_dependencies(package, extra) {
755                if validate_conflicts && dep.complexified_marker.has_conflict_marker() {
756                    dependencies_for_conflict_validation.push((package, dep));
757                }
758                if !dep
759                    .complexified_marker
760                    .evaluate_activated(marker_env, &activated)
761                {
762                    continue;
763                }
764
765                let dep_dist = self.lock().package(dep.index);
766
767                // Add the dependency to the graph.
768                let dep_index = match inverse[dep.index.0] {
769                    None => {
770                        let index = petgraph.add_node(self.package_to_node(
771                            dep_dist,
772                            tags,
773                            build_options,
774                            install_options,
775                            marker_env,
776                        )?);
777                        inverse[dep.index.0] = Some(index);
778                        index
779                    }
780                    Some(index) => {
781                        if matches!(&petgraph[index], Node::Dist { install: false, .. }) {
782                            petgraph[index] = self.package_to_node(
783                                dep_dist,
784                                tags,
785                                build_options,
786                                install_options,
787                                marker_env,
788                            )?;
789                        }
790                        index
791                    }
792                };
793
794                // Add the edge.
795                let index = inverse[package_index.0].expect("queued package has a graph node");
796                petgraph.add_edge(
797                    index,
798                    dep_index,
799                    if let Some(extra) = extra {
800                        Edge::Optional(extra.clone())
801                    } else {
802                        Edge::Prod
803                    },
804                );
805
806                // Push its dependencies on the queue.
807                if seen.insert((dep.index, None)) {
808                    queue.push_back((dep.index, None));
809                }
810                for extra in &dep.extra {
811                    if seen.insert((dep.index, Some(extra))) {
812                        queue.push_back((dep.index, Some(extra)));
813                    }
814                }
815            }
816        }
817
818        // Evaluate conflict markers from concrete roots, not from workspace members that depend on
819        // them. Reject markers that still depend on conflict items outside the resulting subgraph.
820        if !dependencies_for_conflict_validation.is_empty() {
821            let subgraph_packages = inverse
822                .iter()
823                .enumerate()
824                .filter_map(|(package_index, index)| {
825                    index.map(|_| &self.lock().packages[package_index].id.name)
826                })
827                .collect::<FxHashSet<_>>();
828            let selection_context_package = selection_context.package();
829
830            // The environment and conflict state are shared by every dependency, so repeated
831            // markers have the same result.
832            let mut validated_markers = FxHashSet::default();
833            for (package, dependency) in dependencies_for_conflict_validation {
834                if !validated_markers.insert(dependency.complexified_marker) {
835                    continue;
836                }
837                let mut marker = dependency.complexified_marker;
838                for item in self.lock().conflicts().iter().flat_map(ConflictSet::iter) {
839                    if selection_context_package != Some(item.package())
840                        && !subgraph_packages.contains(item.package())
841                    {
842                        continue;
843                    }
844
845                    let active = match item.kind() {
846                        ConflictKind::Project => activated_projects.contains(&item.package()),
847                        ConflictKind::Extra(extra) => {
848                            activated_extras.contains(&(item.package(), extra))
849                        }
850                        ConflictKind::Group(group) => {
851                            activated_groups.contains(&(item.package(), group))
852                        }
853                    };
854                    if active {
855                        marker.assume_conflict_item(item);
856                    } else {
857                        marker.assume_not_conflict_item(item);
858                    }
859                }
860
861                let conflict = marker.conflict_for_environment(marker_env);
862                // All in-subgraph conflict items were resolved above, so a non-constant marker
863                // still depends on a package outside the subgraph.
864                if !conflict.is_constant() {
865                    return Err(LockErrorKind::DependencyConflictOutsideSubgraph {
866                        package: package.id.clone(),
867                        dependency: dependency.package_id.clone(),
868                    }
869                    .into());
870                }
871            }
872        }
873
874        Ok(Resolution::new(petgraph))
875    }
876}
877
878impl<'lock, T> InstallableExt<'lock> for T where T: Installable<'lock> + ?Sized {}
879
880/// An [`Installable`] adapter for materializing concrete packages directly from a [`Lock`].
881struct LockedPackages<'lock> {
882    lock: &'lock Lock,
883    install_path: &'lock Path,
884    project_name: Option<&'lock PackageName>,
885}
886
887impl<'lock> Installable<'lock> for LockedPackages<'lock> {
888    fn install_path(&self) -> &'lock Path {
889        self.install_path
890    }
891
892    fn lock(&self) -> &'lock Lock {
893        self.lock
894    }
895
896    fn roots(&self) -> impl Iterator<Item = &PackageName> {
897        std::iter::empty()
898    }
899
900    fn project_name(&self) -> Option<&PackageName> {
901        self.project_name
902    }
903}
904
905impl Lock {
906    /// Materialize a direct dependency selection from this lock.
907    ///
908    /// Like [`Self::to_resolution`], this materializes the selected dependency's subgraph. It also
909    /// preserves the extras activated by the direct edge and the project production or group
910    /// context used to select a conflict fork.
911    pub fn to_resolution_from_dependency<'lock>(
912        &'lock self,
913        install_path: &'lock Path,
914        dependency: &SelectedDependency<'lock>,
915        project_name: Option<&'lock PackageName>,
916        marker_env: &ResolverMarkerEnvironment,
917        tags: &Tags,
918        build_options: &BuildOptions,
919        install_options: &InstallOptions,
920    ) -> Result<Resolution, LockError> {
921        let selected_package = dependency.package();
922        let Some(index) = self.by_id.get(&selected_package.id) else {
923            return Err(LockErrorKind::RootPackageMissingFromLock {
924                id: selected_package.id.clone(),
925            }
926            .into());
927        };
928        let Some(package) = self.packages.get(index.0) else {
929            return Err(LockErrorKind::RootPackageMissingFromLock {
930                id: selected_package.id.clone(),
931            }
932            .into());
933        };
934        let extras = ExtrasSpecification::from_extra(dependency.extras().cloned().collect())
935            .with_defaults(DefaultExtras::default());
936        let groups = DependencyGroupsWithDefaults::none();
937
938        LockedPackages {
939            lock: self,
940            install_path,
941            project_name,
942        }
943        .to_resolution_from_packages(
944            &[package],
945            None,
946            false,
947            dependency.context(),
948            marker_env,
949            tags,
950            &extras,
951            &groups,
952            build_options,
953            install_options,
954        )
955    }
956
957    /// Materialize the exact dependency subgraph reachable from concrete locked `roots`.
958    ///
959    /// Each root must be a [`Package`] from this lock. Unlike [`Installable::to_resolution`], this
960    /// method does not include requirements or dependency groups attached directly to the lock
961    /// manifest. Extras and dependency groups on the concrete roots are still included according
962    /// to `extras` and `groups`.
963    ///
964    /// Conflict-marker evaluation starts from `roots` and their requested `extras` and `groups`,
965    /// not from workspace members that depend on those roots. The method returns an error if a
966    /// dependency marker still depends on a conflict item outside the resulting subgraph. Use
967    /// [`Installable::to_resolution`] when materializing an existing lock target.
968    ///
969    /// `project_name` identifies the project for project-specific [`InstallOptions`] filters, if
970    /// applicable. Callers are responsible for selecting roots that apply to `marker_env`.
971    pub fn to_resolution<'lock>(
972        &'lock self,
973        install_path: &'lock Path,
974        roots: impl IntoIterator<Item = &'lock Package>,
975        project_name: Option<&'lock PackageName>,
976        marker_env: &ResolverMarkerEnvironment,
977        tags: &Tags,
978        extras: &ExtrasSpecificationWithDefaults,
979        groups: &DependencyGroupsWithDefaults,
980        build_options: &BuildOptions,
981        install_options: &InstallOptions,
982    ) -> Result<Resolution, LockError> {
983        let mut seen = FxHashSet::default();
984        let mut concrete_roots = Vec::new();
985        for root in roots {
986            let Some(index) = self.by_id.get(&root.id) else {
987                return Err(LockErrorKind::RootPackageMissingFromLock {
988                    id: root.id.clone(),
989                }
990                .into());
991            };
992            if seen.insert(&root.id) {
993                let Some(root) = self.packages.get(index.0) else {
994                    return Err(LockErrorKind::RootPackageMissingFromLock {
995                        id: root.id.clone(),
996                    }
997                    .into());
998                };
999                concrete_roots.push(root);
1000            }
1001        }
1002
1003        LockedPackages {
1004            lock: self,
1005            install_path,
1006            project_name,
1007        }
1008        .to_resolution_from_packages(
1009            &concrete_roots,
1010            None,
1011            false,
1012            DependencySelectionContext::None,
1013            marker_env,
1014            tags,
1015            extras,
1016            groups,
1017            build_options,
1018            install_options,
1019        )
1020    }
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025    use std::cell::Cell;
1026    use std::cmp::Ordering;
1027    use std::str::FromStr;
1028    use std::sync::LazyLock;
1029
1030    use petgraph::visit::EdgeRef;
1031    use uv_configuration::{DependencyGroups, ExtrasSpecification};
1032    use uv_distribution_types::Name;
1033    use uv_normalize::{DefaultExtras, DefaultGroups};
1034    use uv_pep508::{MarkerEnvironment, MarkerEnvironmentBuilder};
1035    use uv_platform_tags::{Arch, Os, Platform, TagsOptions};
1036    use uv_warnings::anstream;
1037
1038    use super::*;
1039
1040    static TAGS: LazyLock<Tags> = LazyLock::new(|| {
1041        Tags::from_env(
1042            Platform::new(
1043                Os::Macos {
1044                    major: 14,
1045                    minor: 0,
1046                },
1047                Arch::Aarch64,
1048            ),
1049            (3, 11),
1050            "cpython",
1051            (3, 11),
1052            TagsOptions::default(),
1053        )
1054        .expect("valid tags")
1055    });
1056
1057    static DARWIN_MARKERS: LazyLock<ResolverMarkerEnvironment> =
1058        LazyLock::new(|| ResolverMarkerEnvironment::from(marker_environment("darwin", "Darwin")));
1059
1060    static LINUX_MARKERS: LazyLock<ResolverMarkerEnvironment> =
1061        LazyLock::new(|| ResolverMarkerEnvironment::from(marker_environment("linux", "Linux")));
1062
1063    fn marker_environment(
1064        sys_platform: &'static str,
1065        platform_system: &'static str,
1066    ) -> MarkerEnvironment {
1067        MarkerEnvironment::try_from(MarkerEnvironmentBuilder {
1068            implementation_name: "cpython",
1069            implementation_version: "3.11.5",
1070            os_name: "posix",
1071            platform_machine: "arm64",
1072            platform_python_implementation: "CPython",
1073            platform_release: "23.0.0",
1074            platform_system,
1075            platform_version: "test",
1076            python_full_version: "3.11.5",
1077            python_version: "3.11",
1078            sys_platform,
1079        })
1080        .expect("valid marker environment")
1081    }
1082
1083    fn lock() -> Lock {
1084        toml::from_str(
1085            r#"
1086version = 1
1087revision = 3
1088requires-python = ">=3.11"
1089resolution-markers = [
1090    "sys_platform == 'darwin'",
1091    "sys_platform != 'darwin'",
1092]
1093
1094[manifest]
1095requirements = [{ name = "unrelated" }]
1096
1097[[package]]
1098name = "dev-dependency"
1099version = "1.0.0"
1100source = { registry = "https://example.com/simple" }
1101sdist = { url = "https://example.com/dev_dependency-1.0.0.tar.gz", hash = "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1102
1103[[package]]
1104name = "forked"
1105version = "1.0.0"
1106source = { registry = "https://example.com/simple" }
1107resolution-markers = ["sys_platform == 'darwin'"]
1108sdist = { url = "https://example.com/forked-1.0.0.tar.gz", hash = "sha256:2222222222222222222222222222222222222222222222222222222222222222" }
1109
1110[[package]]
1111name = "forked"
1112version = "2.0.0"
1113source = { registry = "https://example.com/simple" }
1114resolution-markers = ["sys_platform != 'darwin'"]
1115sdist = { url = "https://example.com/forked-2.0.0.tar.gz", hash = "sha256:3333333333333333333333333333333333333333333333333333333333333333" }
1116
1117[[package]]
1118name = "optional-dependency"
1119version = "1.0.0"
1120source = { registry = "https://example.com/simple" }
1121sdist = { url = "https://example.com/optional_dependency-1.0.0.tar.gz", hash = "sha256:4444444444444444444444444444444444444444444444444444444444444444" }
1122
1123[[package]]
1124name = "root-a"
1125version = "1.0.0"
1126source = { registry = "https://example.com/simple" }
1127dependencies = [
1128    { name = "forked", version = "1.0.0", source = { registry = "https://example.com/simple" }, marker = "sys_platform == 'darwin'" },
1129    { name = "forked", version = "2.0.0", source = { registry = "https://example.com/simple" }, marker = "sys_platform != 'darwin'" },
1130    { name = "shared" },
1131]
1132sdist = { url = "https://example.com/root_a-1.0.0.tar.gz", hash = "sha256:5555555555555555555555555555555555555555555555555555555555555555" }
1133
1134[package.optional-dependencies]
1135feature = [{ name = "optional-dependency" }]
1136
1137[package.dependency-groups]
1138dev = [{ name = "dev-dependency" }]
1139
1140[package.metadata]
1141provides-extras = ["feature"]
1142
1143[[package]]
1144name = "root-b"
1145version = "1.0.0"
1146source = { registry = "https://example.com/simple" }
1147dependencies = [{ name = "shared" }]
1148sdist = { url = "https://example.com/root_b-1.0.0.tar.gz", hash = "sha256:6666666666666666666666666666666666666666666666666666666666666666" }
1149
1150[[package]]
1151name = "shared"
1152version = "1.0.0"
1153source = { registry = "https://example.com/simple" }
1154sdist = { url = "https://example.com/shared-1.0.0.tar.gz", hash = "sha256:7777777777777777777777777777777777777777777777777777777777777777" }
1155
1156[[package]]
1157name = "unrelated"
1158version = "1.0.0"
1159source = { registry = "https://example.com/simple" }
1160sdist = { url = "https://example.com/unrelated-1.0.0.tar.gz", hash = "sha256:8888888888888888888888888888888888888888888888888888888888888888" }
1161"#,
1162        )
1163        .expect("valid lock")
1164    }
1165
1166    fn conflict_lock() -> Lock {
1167        toml::from_str(
1168            r#"
1169version = 1
1170revision = 3
1171requires-python = ">=3.11"
1172conflicts = [
1173    [
1174        { package = "tool", extra = "cpu" },
1175        { package = "tool", extra = "gpu" },
1176    ],
1177    [
1178        { package = "project", extra = "foo" },
1179        { package = "project", extra = "bar" },
1180    ],
1181]
1182
1183[[package]]
1184name = "contextual-dependency"
1185version = "1.0.0"
1186source = { registry = "https://example.com/simple" }
1187sdist = { url = "https://example.com/contextual_dependency-1.0.0.tar.gz", hash = "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1188
1189[[package]]
1190name = "contextual-tool"
1191version = "1.0.0"
1192source = { registry = "https://example.com/simple" }
1193dependencies = [
1194    { name = "contextual-dependency", marker = "sys_platform == 'linux' or (sys_platform == 'darwin' and extra == 'extra-7-project-foo')" },
1195]
1196sdist = { url = "https://example.com/contextual_tool-1.0.0.tar.gz", hash = "sha256:2222222222222222222222222222222222222222222222222222222222222222" }
1197
1198[[package]]
1199name = "cpu-backend"
1200version = "1.0.0"
1201source = { registry = "https://example.com/simple" }
1202sdist = { url = "https://example.com/cpu_backend-1.0.0.tar.gz", hash = "sha256:3333333333333333333333333333333333333333333333333333333333333333" }
1203
1204[[package]]
1205name = "gpu-backend"
1206version = "1.0.0"
1207source = { registry = "https://example.com/simple" }
1208sdist = { url = "https://example.com/gpu_backend-1.0.0.tar.gz", hash = "sha256:4444444444444444444444444444444444444444444444444444444444444444" }
1209
1210[[package]]
1211name = "project"
1212version = "1.0.0"
1213source = { registry = "https://example.com/simple" }
1214sdist = { url = "https://example.com/project-1.0.0.tar.gz", hash = "sha256:5555555555555555555555555555555555555555555555555555555555555555" }
1215
1216[package.optional-dependencies]
1217foo = []
1218bar = []
1219
1220[package.metadata]
1221provides-extras = ["foo", "bar"]
1222
1223[[package]]
1224name = "runtime"
1225version = "1.0.0"
1226source = { registry = "https://example.com/simple" }
1227dependencies = [
1228    { name = "cpu-backend", marker = "extra == 'extra-4-tool-cpu'" },
1229    { name = "gpu-backend", marker = "extra == 'extra-4-tool-gpu'" },
1230]
1231sdist = { url = "https://example.com/runtime-1.0.0.tar.gz", hash = "sha256:6666666666666666666666666666666666666666666666666666666666666666" }
1232
1233[[package]]
1234name = "tool"
1235version = "1.0.0"
1236source = { registry = "https://example.com/simple" }
1237dependencies = [{ name = "runtime" }]
1238sdist = { url = "https://example.com/tool-1.0.0.tar.gz", hash = "sha256:7777777777777777777777777777777777777777777777777777777777777777" }
1239
1240[package.optional-dependencies]
1241cpu = []
1242gpu = []
1243
1244[package.metadata]
1245provides-extras = ["cpu", "gpu"]
1246"#,
1247        )
1248        .expect("valid lock")
1249    }
1250
1251    fn dependency_selection_lock() -> Lock {
1252        toml::from_str(
1253            r#"
1254version = 1
1255revision = 3
1256requires-python = ">=3.11"
1257conflicts = [[
1258    { package = "project", group = "dev" },
1259    { package = "project", group = "other" },
1260]]
1261
1262[[package]]
1263name = "contextual-dev-dependency"
1264version = "1.0.0"
1265source = { registry = "https://example.com/simple" }
1266sdist = { url = "https://example.com/contextual_dev_dependency-1.0.0.tar.gz", hash = "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1267
1268[[package]]
1269name = "contextual-other-dependency"
1270version = "1.0.0"
1271source = { registry = "https://example.com/simple" }
1272sdist = { url = "https://example.com/contextual_other_dependency-1.0.0.tar.gz", hash = "sha256:2222222222222222222222222222222222222222222222222222222222222222" }
1273
1274[[package]]
1275name = "optional-dependency"
1276version = "1.0.0"
1277source = { registry = "https://example.com/simple" }
1278sdist = { url = "https://example.com/optional_dependency-1.0.0.tar.gz", hash = "sha256:3333333333333333333333333333333333333333333333333333333333333333" }
1279
1280[[package]]
1281name = "project"
1282version = "1.0.0"
1283source = { virtual = "." }
1284
1285[package.dependency-groups]
1286dev = [{ name = "tool", extra = ["cli"] }]
1287other = [{ name = "tool" }]
1288
1289[[package]]
1290name = "tool"
1291version = "1.0.0"
1292source = { registry = "https://example.com/simple" }
1293dependencies = [
1294    { name = "contextual-dev-dependency", marker = "extra == 'group-7-project-dev'" },
1295    { name = "contextual-other-dependency", marker = "extra == 'group-7-project-other'" },
1296]
1297sdist = { url = "https://example.com/tool-1.0.0.tar.gz", hash = "sha256:4444444444444444444444444444444444444444444444444444444444444444" }
1298
1299[package.optional-dependencies]
1300cli = [{ name = "optional-dependency" }]
1301
1302[package.metadata]
1303provides-extras = ["cli"]
1304"#,
1305        )
1306        .expect("valid lock")
1307    }
1308
1309    fn package<'lock>(lock: &'lock Lock, name: &str, version: &str) -> &'lock Package {
1310        lock.packages()
1311            .iter()
1312            .find(|package| {
1313                package.name().as_ref() == name
1314                    && package
1315                        .version()
1316                        .is_some_and(|package_version| package_version.to_string() == version)
1317            })
1318            .expect("locked package")
1319    }
1320
1321    fn materialize(
1322        lock: &Lock,
1323        roots: &[&Package],
1324        marker_env: &ResolverMarkerEnvironment,
1325    ) -> Resolution {
1326        let extras = ExtrasSpecification::from_all_extras().with_defaults(DefaultExtras::default());
1327        let groups = DependencyGroups::from_all_groups().with_defaults(DefaultGroups::default());
1328        lock.to_resolution(
1329            Path::new("."),
1330            roots.iter().copied(),
1331            None,
1332            marker_env,
1333            &TAGS,
1334            &extras,
1335            &groups,
1336            &BuildOptions::default(),
1337            &InstallOptions::default(),
1338        )
1339        .expect("valid resolution")
1340    }
1341
1342    fn materialize_with_extras(
1343        lock: &Lock,
1344        roots: &[&Package],
1345        marker_env: &ResolverMarkerEnvironment,
1346        extras: &ExtrasSpecification,
1347    ) -> Result<Resolution, LockError> {
1348        let extras = extras.with_defaults(DefaultExtras::default());
1349        let groups = DependencyGroupsWithDefaults::none();
1350        lock.to_resolution(
1351            Path::new("."),
1352            roots.iter().copied(),
1353            None,
1354            marker_env,
1355            &TAGS,
1356            &extras,
1357            &groups,
1358            &BuildOptions::default(),
1359            &InstallOptions::default(),
1360        )
1361    }
1362
1363    fn materialize_selected_dependency(lock: &Lock, group: &str) -> Resolution {
1364        let project_name = PackageName::from_str("project").expect("valid package name");
1365        let dependency_name = PackageName::from_str("tool").expect("valid package name");
1366        let group = GroupName::from_str(group).expect("valid group name");
1367        let selection = lock
1368            .dependency_selection(
1369                Some(&project_name),
1370                &dependency_name,
1371                DARWIN_MARKERS.markers(),
1372            )
1373            .expect("unique dependency selection");
1374        let dependency = selection.group(&group).expect("group dependency");
1375
1376        lock.to_resolution_from_dependency(
1377            Path::new("."),
1378            dependency,
1379            Some(&project_name),
1380            &DARWIN_MARKERS,
1381            &TAGS,
1382            &BuildOptions::default(),
1383            &InstallOptions::default(),
1384        )
1385        .expect("valid resolution")
1386    }
1387
1388    #[test]
1389    fn unrelated_packages_do_not_change_dependency_identity() {
1390        let original = lock();
1391        let input = format!(
1392            "{}\n{}",
1393            original.to_toml().expect("valid lock TOML"),
1394            r#"
1395[[package]]
1396name = "aaa-unrelated"
1397version = "1.0.0"
1398source = { registry = "https://example.com/simple" }
1399"#
1400        );
1401        let extended = Lock::from_toml(&input).expect("valid extended lock");
1402        let original_root = package(&original, "root-a", "1.0.0");
1403        let extended_root = package(&extended, "root-a", "1.0.0");
1404
1405        assert_eq!(original_root, extended_root);
1406        assert_eq!(
1407            original_root.dependencies.cmp(&extended_root.dependencies),
1408            Ordering::Equal,
1409        );
1410        assert_eq!(
1411            graph_snapshot(&materialize(&original, &[original_root], &DARWIN_MARKERS)),
1412            graph_snapshot(&materialize(&extended, &[extended_root], &DARWIN_MARKERS)),
1413        );
1414    }
1415
1416    struct OverridingInstallable<'lock> {
1417        lock: &'lock Lock,
1418        root_name: &'lock PackageName,
1419        package_to_node_calls: Cell<usize>,
1420    }
1421
1422    impl<'lock> Installable<'lock> for OverridingInstallable<'lock> {
1423        fn install_path(&self) -> &'lock Path {
1424            Path::new(".")
1425        }
1426
1427        fn lock(&self) -> &'lock Lock {
1428            self.lock
1429        }
1430
1431        fn roots(&self) -> impl Iterator<Item = &PackageName> {
1432            std::iter::once(self.root_name)
1433        }
1434
1435        fn project_name(&self) -> Option<&PackageName> {
1436            None
1437        }
1438
1439        fn package_to_node(
1440            &self,
1441            _package: &Package,
1442            _tags: &Tags,
1443            _build_options: &BuildOptions,
1444            _install_options: &InstallOptions,
1445            _marker_env: &ResolverMarkerEnvironment,
1446        ) -> Result<Node, LockError> {
1447            self.package_to_node_calls
1448                .set(self.package_to_node_calls.get() + 1);
1449            Ok(Node::Root)
1450        }
1451    }
1452
1453    fn graph_snapshot(resolution: &Resolution) -> (Vec<String>, Vec<String>) {
1454        let graph = resolution.graph();
1455        let labels = graph
1456            .node_weights()
1457            .map(|node| match node {
1458                Node::Root => "root".to_string(),
1459                Node::Dist {
1460                    dist,
1461                    hashes,
1462                    install,
1463                } => format!(
1464                    "{}=={} (install: {install}, hashes: {})",
1465                    dist.name(),
1466                    dist.version()
1467                        .map(ToString::to_string)
1468                        .unwrap_or_else(|| "<dynamic>".to_string()),
1469                    hashes.iter().map(ToString::to_string).join(", ")
1470                ),
1471            })
1472            .collect::<Vec<_>>();
1473        let mut nodes = labels.clone();
1474        nodes.sort_unstable();
1475        let mut edges = graph
1476            .edge_references()
1477            .map(|edge| {
1478                format!(
1479                    "{} --{:?}--> {}",
1480                    labels[edge.source().index()],
1481                    edge.weight(),
1482                    labels[edge.target().index()]
1483                )
1484            })
1485            .collect::<Vec<_>>();
1486        edges.sort_unstable();
1487        (nodes, edges)
1488    }
1489
1490    #[test]
1491    fn materializes_multiple_concrete_roots_with_shared_dependencies() {
1492        let lock = lock();
1493        let resolution = materialize(
1494            &lock,
1495            &[
1496                package(&lock, "root-a", "1.0.0"),
1497                package(&lock, "root-b", "1.0.0"),
1498            ],
1499            &DARWIN_MARKERS,
1500        );
1501
1502        insta::with_settings!({
1503            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1504        }, {
1505            insta::assert_debug_snapshot!(graph_snapshot(&resolution), @r#"
1506        (
1507            [
1508                "dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1509                "forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1510                "optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1511                "root",
1512                "root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1513                "root-b==1.0.0 (install: true, hashes: sha256:[HASH])",
1514                "shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1515            ],
1516            [
1517                "root --Prod--> root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1518                "root --Prod--> root-b==1.0.0 (install: true, hashes: sha256:[HASH])",
1519                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Dev(GroupName(\"dev\"))--> dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1520                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Optional(ExtraName(\"feature\"))--> optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1521                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1522                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1523                "root-b==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1524            ],
1525        )
1526            "#);
1527        });
1528    }
1529
1530    #[test]
1531    fn materializes_group_root_referenced_by_production_dependency() {
1532        let lock = lock();
1533        let project = package(&lock, "root-a", "1.0.0");
1534        let group_root = package(&lock, "shared", "1.0.0");
1535        let extras = ExtrasSpecification::default().with_defaults(DefaultExtras::default());
1536        let groups = DependencyGroupsWithDefaults::none();
1537
1538        let resolution = LockedPackages {
1539            lock: &lock,
1540            install_path: Path::new("."),
1541            project_name: Some(project.name()),
1542        }
1543        .to_resolution_from_packages(
1544            &[project],
1545            Some(group_root),
1546            false,
1547            DependencySelectionContext::None,
1548            &DARWIN_MARKERS,
1549            &TAGS,
1550            &extras,
1551            &groups,
1552            &BuildOptions::default(),
1553            &InstallOptions::default(),
1554        )
1555        .expect("valid resolution");
1556
1557        assert!(
1558            resolution
1559                .distributions()
1560                .any(|distribution| distribution.name() == group_root.name())
1561        );
1562    }
1563
1564    #[test]
1565    fn materializes_the_selected_universal_lock_fork() {
1566        let lock = lock();
1567        let root = package(&lock, "root-a", "1.0.0");
1568        let darwin = materialize(&lock, &[root], &DARWIN_MARKERS);
1569        let linux = materialize(&lock, &[root], &LINUX_MARKERS);
1570        let concrete_fork =
1571            materialize(&lock, &[package(&lock, "forked", "1.0.0")], &DARWIN_MARKERS);
1572
1573        insta::with_settings!({
1574            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1575        }, {
1576            insta::assert_debug_snapshot!(graph_snapshot(&darwin), @r#"
1577        (
1578            [
1579                "dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1580                "forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1581                "optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1582                "root",
1583                "root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1584                "shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1585            ],
1586            [
1587                "root --Prod--> root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1588                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Dev(GroupName(\"dev\"))--> dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1589                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Optional(ExtraName(\"feature\"))--> optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1590                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1591                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1592            ],
1593        )
1594        "#);
1595            insta::assert_debug_snapshot!(graph_snapshot(&linux), @r#"
1596        (
1597            [
1598                "dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1599                "forked==2.0.0 (install: true, hashes: sha256:[HASH])",
1600                "optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1601                "root",
1602                "root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1603                "shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1604            ],
1605            [
1606                "root --Prod--> root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1607                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Dev(GroupName(\"dev\"))--> dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1608                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Optional(ExtraName(\"feature\"))--> optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1609                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> forked==2.0.0 (install: true, hashes: sha256:[HASH])",
1610                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1611            ],
1612        )
1613        "#);
1614            insta::assert_debug_snapshot!(graph_snapshot(&concrete_fork), @r#"
1615        (
1616            [
1617                "forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1618                "root",
1619            ],
1620            [
1621                "root --Prod--> forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1622            ],
1623        )
1624            "#);
1625        });
1626    }
1627
1628    #[test]
1629    fn materializes_conflicting_extras_within_the_synthetic_root() {
1630        let lock = conflict_lock();
1631        let extras =
1632            ExtrasSpecification::from_extra(vec!["cpu".parse().expect("valid extra name")]);
1633        let resolution = materialize_with_extras(
1634            &lock,
1635            &[package(&lock, "tool", "1.0.0")],
1636            &DARWIN_MARKERS,
1637            &extras,
1638        )
1639        .expect("conflict markers are resolved within the subgraph");
1640
1641        insta::with_settings!({
1642            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1643        }, {
1644            insta::assert_debug_snapshot!(graph_snapshot(&resolution), @r#"
1645        (
1646            [
1647                "cpu-backend==1.0.0 (install: true, hashes: sha256:[HASH])",
1648                "root",
1649                "runtime==1.0.0 (install: true, hashes: sha256:[HASH])",
1650                "tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1651            ],
1652            [
1653                "root --Prod--> tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1654                "runtime==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> cpu-backend==1.0.0 (install: true, hashes: sha256:[HASH])",
1655                "tool==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> runtime==1.0.0 (install: true, hashes: sha256:[HASH])",
1656            ],
1657        )
1658        "#);
1659        });
1660    }
1661
1662    #[test]
1663    fn materializes_selected_dependency_extras() {
1664        let resolution = materialize_selected_dependency(&dependency_selection_lock(), "dev");
1665
1666        insta::with_settings!({
1667            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1668        }, {
1669            insta::assert_debug_snapshot!(graph_snapshot(&resolution), @r#"
1670        (
1671            [
1672                "contextual-dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1673                "optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1674                "root",
1675                "tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1676            ],
1677            [
1678                "root --Prod--> tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1679                "tool==1.0.0 (install: true, hashes: sha256:[HASH]) --Optional(ExtraName(\"cli\"))--> optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1680                "tool==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> contextual-dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1681            ],
1682        )
1683        "#);
1684        });
1685    }
1686
1687    #[test]
1688    fn materializes_selected_dependency_project_conflict_context() {
1689        let resolution = materialize_selected_dependency(&dependency_selection_lock(), "other");
1690
1691        insta::with_settings!({
1692            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1693        }, {
1694            insta::assert_debug_snapshot!(graph_snapshot(&resolution), @r#"
1695        (
1696            [
1697                "contextual-other-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1698                "root",
1699                "tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1700            ],
1701            [
1702                "root --Prod--> tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1703                "tool==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> contextual-other-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1704            ],
1705        )
1706        "#);
1707        });
1708    }
1709
1710    #[test]
1711    fn rejects_conflicts_outside_the_synthetic_root() {
1712        let lock = conflict_lock();
1713        let root = package(&lock, "contextual-tool", "1.0.0");
1714        let extras = ExtrasSpecification::default();
1715
1716        let error = materialize_with_extras(&lock, &[root], &DARWIN_MARKERS, &extras)
1717            .expect_err("Darwin dependency depends on the project extra");
1718        let error = error.to_string();
1719        let error = anstream::adapter::strip_str(&error);
1720        insta::assert_snapshot!(error, @"Cannot materialize dependency `contextual-dependency==1.0.0 @ registry+https://example.com/simple` of `contextual-tool==1.0.0 @ registry+https://example.com/simple` because its conflict marker depends on a package outside the selected subgraph");
1721
1722        let linux = materialize_with_extras(&lock, &[root], &LINUX_MARKERS, &extras)
1723            .expect("the dependency is unconditional on Linux");
1724        insta::with_settings!({
1725            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1726        }, {
1727            insta::assert_debug_snapshot!(graph_snapshot(&linux), @r#"
1728        (
1729            [
1730                "contextual-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1731                "contextual-tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1732                "root",
1733            ],
1734            [
1735                "contextual-tool==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> contextual-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1736                "root --Prod--> contextual-tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1737            ],
1738        )
1739        "#);
1740        });
1741    }
1742
1743    #[test]
1744    fn installable_to_resolution_preserves_node_overrides() {
1745        let mut lock = lock();
1746        lock.manifest.requirements.clear();
1747        let target = OverridingInstallable {
1748            root_name: package(&lock, "root-a", "1.0.0").name(),
1749            lock: &lock,
1750            package_to_node_calls: Cell::new(0),
1751        };
1752        let extras = ExtrasSpecification::from_all_extras().with_defaults(DefaultExtras::default());
1753        let groups = DependencyGroups::from_all_groups().with_defaults(DefaultGroups::default());
1754
1755        target
1756            .to_resolution(
1757                &DARWIN_MARKERS,
1758                &TAGS,
1759                &extras,
1760                &groups,
1761                &BuildOptions::default(),
1762                &InstallOptions::default(),
1763            )
1764            .expect("valid resolution");
1765
1766        assert!(target.package_to_node_calls.get() > 0);
1767    }
1768}