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::{ConflictKind, ConflictSet, ResolverMarkerEnvironment};
18
19use crate::lock::{Dependency, HashedDist, LockErrorKind, Package, PackageId, TagPolicy};
20use crate::{Lock, LockError, UniversalMarker};
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
35/// Record another condition under which a locked package and optional extra are reachable.
36///
37/// Returns `true` when the combined reachability changed.
38fn add_reachability<'lock>(
39    reachability: &mut FxHashMap<(&'lock PackageId, Option<&'lock ExtraName>), UniversalMarker>,
40    key: (&'lock PackageId, Option<&'lock ExtraName>),
41    marker: UniversalMarker,
42) -> bool {
43    match reachability.entry(key) {
44        Entry::Occupied(mut entry) => {
45            let mut combined = *entry.get();
46            combined.or(marker);
47            if combined == *entry.get() {
48                false
49            } else {
50                entry.insert(combined);
51                true
52            }
53        }
54        Entry::Vacant(entry) => {
55            entry.insert(marker);
56            true
57        }
58    }
59}
60
61pub trait Installable<'lock> {
62    /// Return the root install path.
63    fn install_path(&self) -> &'lock Path;
64
65    /// Return the [`Lock`] to install.
66    fn lock(&self) -> &'lock Lock;
67
68    /// Return the [`PackageName`] of the root packages in the target.
69    fn roots(&self) -> impl Iterator<Item = &PackageName>;
70
71    /// Return the [`PackageName`] of the target, if available.
72    fn project_name(&self) -> Option<&PackageName>;
73
74    /// Convert the [`Lock`] to a [`Resolution`] using the given marker environment, tags, and root.
75    fn to_resolution(
76        &self,
77        marker_env: &ResolverMarkerEnvironment,
78        tags: &Tags,
79        extras: &ExtrasSpecificationWithDefaults,
80        groups: &DependencyGroupsWithDefaults,
81        build_options: &BuildOptions,
82        install_options: &InstallOptions,
83    ) -> Result<Resolution, LockError> {
84        let roots = self
85            .roots()
86            .map(|root_name| {
87                self.lock()
88                    .find_by_name(root_name)
89                    .map_err(|_| LockErrorKind::MultipleRootPackages {
90                        name: root_name.clone(),
91                    })?
92                    .ok_or_else(|| {
93                        LockError::from(LockErrorKind::MissingRootPackage {
94                            name: root_name.clone(),
95                        })
96                    })
97            })
98            .collect::<Result<Vec<_>, LockError>>()?;
99
100        InstallableExt::to_resolution_from_packages(
101            self,
102            &roots,
103            true,
104            marker_env,
105            tags,
106            extras,
107            groups,
108            build_options,
109            install_options,
110        )
111    }
112
113    /// Create an installable [`Node`] from a [`Package`].
114    fn installable_node(
115        &self,
116        package: &Package,
117        tags: &Tags,
118        marker_env: &ResolverMarkerEnvironment,
119        build_options: &BuildOptions,
120    ) -> Result<Node, LockError> {
121        let tag_policy = TagPolicy::Required(tags);
122        let HashedDist { dist, hashes } =
123            package.to_dist(self.install_path(), tag_policy, build_options, marker_env)?;
124        let version = package.version().cloned();
125        let dist = ResolvedDist::Installable {
126            dist: Arc::new(dist),
127            version,
128        };
129        Ok(Node::Dist {
130            dist,
131            hashes,
132            install: true,
133        })
134    }
135
136    /// Create a non-installable [`Node`] from a [`Package`].
137    fn non_installable_node(
138        &self,
139        package: &Package,
140        tags: &Tags,
141        marker_env: &ResolverMarkerEnvironment,
142    ) -> Result<Node, LockError> {
143        let HashedDist { dist, .. } = package.to_dist(
144            self.install_path(),
145            TagPolicy::Preferred(tags),
146            &BuildOptions::default(),
147            marker_env,
148        )?;
149        let version = package.version().cloned();
150        let dist = ResolvedDist::Installable {
151            dist: Arc::new(dist),
152            version,
153        };
154        let hashes = package.hashes();
155        Ok(Node::Dist {
156            dist,
157            hashes,
158            install: false,
159        })
160    }
161
162    /// Convert a lockfile entry to a graph [`Node`].
163    fn package_to_node(
164        &self,
165        package: &Package,
166        tags: &Tags,
167        build_options: &BuildOptions,
168        install_options: &InstallOptions,
169        marker_env: &ResolverMarkerEnvironment,
170    ) -> Result<Node, LockError> {
171        if install_options.include_package(
172            package.as_install_target(),
173            self.project_name(),
174            self.lock().members(),
175        ) {
176            self.installable_node(package, tags, marker_env, build_options)
177        } else {
178            self.non_installable_node(package, tags, marker_env)
179        }
180    }
181}
182
183/// Internal lock-to-resolution implementation shared by [`Installable`] and [`Lock`].
184trait InstallableExt<'lock>: Installable<'lock> {
185    /// Convert concrete locked packages to a [`Resolution`].
186    ///
187    /// `include_manifest` controls whether requirements attached directly to the lock target are
188    /// included in addition to `roots`.
189    fn to_resolution_from_packages(
190        &self,
191        roots: &[&Package],
192        include_manifest: bool,
193        marker_env: &ResolverMarkerEnvironment,
194        tags: &Tags,
195        extras: &ExtrasSpecificationWithDefaults,
196        groups: &DependencyGroupsWithDefaults,
197        build_options: &BuildOptions,
198        install_options: &InstallOptions,
199    ) -> Result<Resolution, LockError> {
200        let size_guess = self.lock().packages.len();
201        let mut petgraph = Graph::with_capacity(size_guess, size_guess);
202        let mut inverse = FxHashMap::with_capacity_and_hasher(size_guess, FxBuildHasher);
203
204        let mut queue: VecDeque<(&Package, Option<&ExtraName>)> = VecDeque::new();
205        let mut seen = FxHashSet::default();
206        let mut conflict_reachability = FxHashMap::default();
207        let mut activated_projects: Vec<&PackageName> = vec![];
208        let mut activated_extras: Vec<(&PackageName, &ExtraName)> = vec![];
209        let mut activated_groups: Vec<(&PackageName, &GroupName)> = vec![];
210        let has_conflicts = !self.lock().conflicts().is_empty();
211        let validate_conflicts = !include_manifest && has_conflicts;
212        let mut dependencies_for_conflict_validation = vec![];
213
214        let root = petgraph.add_node(Node::Root);
215
216        // Determine the set of activated extras and groups, from the root.
217        //
218        // TODO(charlie): This isn't quite right. Below, when we add the dependency groups to the
219        // graph, we rely on the activated extras and dependency groups, to evaluate the conflict
220        // marker. But at that point, we don't know the full set of activated extras; this is only
221        // computed below. We somehow need to add the dependency groups _after_ we've computed all
222        // enabled extras, but the groups themselves could depend on the set of enabled extras.
223        if has_conflicts {
224            for dist in roots.iter().copied() {
225                // Track the activated extras.
226                if groups.prod() {
227                    activated_projects.push(&dist.id.name);
228                    for extra in extras.extra_names(dist.optional_dependencies.keys()) {
229                        activated_extras.push((&dist.id.name, extra));
230                    }
231                }
232
233                // Track the activated groups.
234                for group in dist
235                    .dependency_groups
236                    .keys()
237                    .filter(|group| groups.contains(group))
238                {
239                    activated_groups.push((&dist.id.name, group));
240                }
241            }
242        }
243
244        // Initialize the workspace roots.
245        let mut initialized_roots = vec![];
246        for dist in roots.iter().copied() {
247            // Add the workspace package to the graph.
248            let index = petgraph.add_node(if groups.prod() {
249                self.package_to_node(dist, tags, build_options, install_options, marker_env)?
250            } else {
251                self.non_installable_node(dist, tags, marker_env)?
252            });
253            inverse.insert(&dist.id, index);
254
255            // Add an edge from the root.
256            petgraph.add_edge(root, index, Edge::Prod);
257
258            // Push the package onto the queue.
259            initialized_roots.push((dist, index));
260        }
261
262        // Add the workspace dependencies to the queue.
263        for (dist, index) in initialized_roots {
264            if groups.prod() {
265                // Push its dependencies onto the queue.
266                queue.push_back((dist, None));
267                add_reachability(
268                    &mut conflict_reachability,
269                    (&dist.id, None),
270                    UniversalMarker::TRUE,
271                );
272                for extra in extras.extra_names(dist.optional_dependencies.keys()) {
273                    queue.push_back((dist, Some(extra)));
274                    add_reachability(
275                        &mut conflict_reachability,
276                        (&dist.id, Some(extra)),
277                        UniversalMarker::TRUE,
278                    );
279                }
280            }
281
282            // Add any dev dependencies.
283            for (group, dep) in dist
284                .dependency_groups
285                .iter()
286                .filter_map(|(group, deps)| {
287                    if groups.contains(group) {
288                        Some(deps.iter().map(move |dep| (group, dep)))
289                    } else {
290                        None
291                    }
292                })
293                .flatten()
294            {
295                if validate_conflicts && dep.complexified_marker.has_conflict_marker() {
296                    dependencies_for_conflict_validation.push((dist, dep));
297                }
298                let additional_activated_extras = newly_activated_extras(dep, &activated_extras);
299                if !dep.complexified_marker.evaluate(
300                    marker_env,
301                    activated_projects.iter().copied(),
302                    activated_extras
303                        .iter()
304                        .chain(additional_activated_extras.iter())
305                        .copied(),
306                    activated_groups.iter().copied(),
307                ) {
308                    continue;
309                }
310
311                let dep_dist = self.lock().find_by_id(&dep.package_id);
312
313                // Add the package to the graph.
314                let dep_index = match inverse.entry(&dep.package_id) {
315                    Entry::Vacant(entry) => {
316                        let index = petgraph.add_node(self.package_to_node(
317                            dep_dist,
318                            tags,
319                            build_options,
320                            install_options,
321                            marker_env,
322                        )?);
323                        entry.insert(index);
324                        index
325                    }
326                    Entry::Occupied(entry) => {
327                        // Critically, if the package is already in the graph, then it's a workspace
328                        // member. If it was omitted due to, e.g., `--only-dev`, but is itself
329                        // referenced as a development dependency, then we need to re-enable it.
330                        let index = *entry.get();
331                        let node = &mut petgraph[index];
332                        if !groups.prod() {
333                            *node = self.package_to_node(
334                                dep_dist,
335                                tags,
336                                build_options,
337                                install_options,
338                                marker_env,
339                            )?;
340                        }
341                        index
342                    }
343                };
344
345                petgraph.add_edge(
346                    index,
347                    dep_index,
348                    // This is OK because we are resolving to a resolution for
349                    // a specific marker environment and set of extras/groups.
350                    // So at this point, we know the extras/groups have been
351                    // satisfied, so we can safely drop the conflict marker.
352                    Edge::Dev(group.clone()),
353                );
354
355                // Push its dependencies on the queue.
356                add_reachability(
357                    &mut conflict_reachability,
358                    (&dep.package_id, None),
359                    dep.complexified_marker,
360                );
361                if seen.insert((&dep.package_id, None)) {
362                    queue.push_back((dep_dist, None));
363                }
364                for extra in &dep.extra {
365                    add_reachability(
366                        &mut conflict_reachability,
367                        (&dep.package_id, Some(extra)),
368                        dep.complexified_marker,
369                    );
370                    if seen.insert((&dep.package_id, Some(extra))) {
371                        queue.push_back((dep_dist, Some(extra)));
372                    }
373                }
374            }
375        }
376
377        if include_manifest {
378            // Add any requirements that are exclusive to the workspace root (e.g., dependencies in
379            // PEP 723 scripts).
380            for dependency in self.lock().requirements() {
381                if !dependency.marker.evaluate(marker_env, &[]) {
382                    continue;
383                }
384
385                let root_name = &dependency.name;
386                let dist = self
387                    .lock()
388                    .find_by_markers(root_name, marker_env)
389                    .map_err(|_| LockErrorKind::MultipleRootPackages {
390                        name: root_name.clone(),
391                    })?
392                    .ok_or_else(|| LockErrorKind::MissingRootPackage {
393                        name: root_name.clone(),
394                    })?;
395
396                // Add the package to the graph.
397                let index = petgraph.add_node(if groups.prod() {
398                    self.package_to_node(dist, tags, build_options, install_options, marker_env)?
399                } else {
400                    self.non_installable_node(dist, tags, marker_env)?
401                });
402                inverse.insert(&dist.id, index);
403
404                // Add the edge.
405                petgraph.add_edge(root, index, Edge::Prod);
406
407                // Push its dependencies on the queue.
408                add_reachability(
409                    &mut conflict_reachability,
410                    (&dist.id, None),
411                    UniversalMarker::TRUE,
412                );
413                if seen.insert((&dist.id, None)) {
414                    queue.push_back((dist, None));
415                }
416                for extra in &dependency.extras {
417                    add_reachability(
418                        &mut conflict_reachability,
419                        (&dist.id, Some(extra)),
420                        UniversalMarker::TRUE,
421                    );
422                    if seen.insert((&dist.id, Some(extra))) {
423                        queue.push_back((dist, Some(extra)));
424                    }
425                }
426            }
427
428            // Add any dependency groups that are exclusive to the workspace root (e.g., dev
429            // dependencies in non-project workspace roots).
430            for (group, dependency) in self
431                .lock()
432                .dependency_groups()
433                .iter()
434                .filter_map(|(group, deps)| {
435                    if groups.contains(group) {
436                        Some(deps.iter().map(move |dep| (group, dep)))
437                    } else {
438                        None
439                    }
440                })
441                .flatten()
442            {
443                if !dependency.marker.evaluate(marker_env, &[]) {
444                    continue;
445                }
446
447                let root_name = &dependency.name;
448                let dist = self
449                    .lock()
450                    .find_by_markers(root_name, marker_env)
451                    .map_err(|_| LockErrorKind::MultipleRootPackages {
452                        name: root_name.clone(),
453                    })?
454                    .ok_or_else(|| LockErrorKind::MissingRootPackage {
455                        name: root_name.clone(),
456                    })?;
457
458                // Add the package to the graph.
459                let index = match inverse.entry(&dist.id) {
460                    Entry::Vacant(entry) => {
461                        let index = petgraph.add_node(self.package_to_node(
462                            dist,
463                            tags,
464                            build_options,
465                            install_options,
466                            marker_env,
467                        )?);
468                        entry.insert(index);
469                        index
470                    }
471                    Entry::Occupied(entry) => {
472                        // Critically, if the package is already in the graph, then it's a workspace
473                        // member. If it was omitted due to, e.g., `--only-dev`, but is itself
474                        // referenced as a development dependency, then we need to re-enable it.
475                        let index = *entry.get();
476                        let node = &mut petgraph[index];
477                        if !groups.prod() {
478                            *node = self.package_to_node(
479                                dist,
480                                tags,
481                                build_options,
482                                install_options,
483                                marker_env,
484                            )?;
485                        }
486                        index
487                    }
488                };
489
490                // Add the edge.
491                petgraph.add_edge(root, index, Edge::Dev(group.clone()));
492
493                // Push its dependencies on the queue.
494                add_reachability(
495                    &mut conflict_reachability,
496                    (&dist.id, None),
497                    UniversalMarker::TRUE,
498                );
499                if seen.insert((&dist.id, None)) {
500                    queue.push_back((dist, None));
501                }
502                for extra in &dependency.extras {
503                    add_reachability(
504                        &mut conflict_reachability,
505                        (&dist.id, Some(extra)),
506                        UniversalMarker::TRUE,
507                    );
508                    if seen.insert((&dist.id, Some(extra))) {
509                        queue.push_back((dist, Some(extra)));
510                    }
511                }
512            }
513        }
514
515        // Below, we traverse the dependency graph in a breadth first manner
516        // twice. It's only in the second traversal that we actually build
517        // up our resolution graph. In the first traversal, we accumulate all
518        // activated extras. This includes the extras explicitly enabled on
519        // the CLI (which were gathered above) and the extras enabled via
520        // dependency specifications like `foo[extra]`. We need to do this
521        // to correctly support conflicting extras.
522        //
523        // In particular, the way conflicting extras works is by forking the
524        // resolver based on the extras that are declared as conflicting. But
525        // this forking needs to be made manifest somehow in the lock file to
526        // avoid multiple versions of the same package being installed into the
527        // environment. This is why "conflict markers" were invented. For
528        // example, you might have both `torch` and `torch+cpu` in your
529        // dependency graph, where the latter is only enabled when the `cpu`
530        // extra is enabled, and the former is specifically *not* enabled
531        // when the `cpu` extra is enabled.
532        //
533        // In order to evaluate these conflict markers correctly, we need to
534        // know whether the `cpu` extra is enabled when we visit the `torch`
535        // dependency. If we think it's disabled, then we'll erroneously
536        // include it if the extra is actually enabled. But in order to tell
537        // if it's enabled, we need to traverse the entire dependency graph
538        // first to inspect which extras are enabled!
539        //
540        // Of course, we don't need to do this at all if there aren't any
541        // conflicts. In which case, we skip all of this and just do the one
542        // traversal below.
543        if has_conflicts {
544            let mut activated_extras_set: BTreeSet<(&PackageName, &ExtraName)> =
545                activated_extras.iter().copied().collect();
546            let mut queue = queue.clone();
547            let mut reachability = conflict_reachability;
548            while let Some((package, extra)) = queue.pop_front() {
549                let Some(parent_reachability) = reachability.get(&(&package.id, extra)).copied()
550                else {
551                    continue;
552                };
553                let deps = if let Some(extra) = extra {
554                    Either::Left(
555                        package
556                            .optional_dependencies
557                            .get(extra)
558                            .into_iter()
559                            .flatten(),
560                    )
561                } else {
562                    Either::Right(package.dependencies.iter())
563                };
564                for dep in deps {
565                    let mut dep_reachability = dep.complexified_marker;
566                    dep_reachability.and(parent_reachability);
567                    let additional_activated_extras =
568                        newly_activated_extras(dep, &activated_extras);
569                    if !dep_reachability.evaluate(
570                        marker_env,
571                        activated_projects.iter().copied(),
572                        activated_extras
573                            .iter()
574                            .chain(additional_activated_extras.iter())
575                            .copied(),
576                        activated_groups.iter().copied(),
577                    ) {
578                        continue;
579                    }
580                    // The dependency can still be visited provisionally before all activated
581                    // extras are known. The second traversal below will exclude it once those
582                    // extras are available. Crucially, `dep_reachability` includes the conditions
583                    // required to reach the parent package: dependency markers may have been
584                    // simplified under those conditions and cannot stand alone during this
585                    // preliminary traversal. Otherwise, an unreachable package could activate an
586                    // extra and cause the conflict check below to report a false positive.
587
588                    for key in additional_activated_extras {
589                        activated_extras_set.insert(key);
590                        activated_extras.push(key);
591                    }
592                    let dep_dist = self.lock().find_by_id(&dep.package_id);
593                    // Push its dependencies on the queue.
594                    if add_reachability(
595                        &mut reachability,
596                        (&dep.package_id, None),
597                        dep_reachability,
598                    ) {
599                        queue.push_back((dep_dist, None));
600                    }
601                    for extra in &dep.extra {
602                        if add_reachability(
603                            &mut reachability,
604                            (&dep.package_id, Some(extra)),
605                            dep_reachability,
606                        ) {
607                            queue.push_back((dep_dist, Some(extra)));
608                        }
609                    }
610                }
611            }
612            // At time of writing, it's somewhat expected that the set of
613            // conflicting extras is pretty small. With that said, the
614            // time complexity of the following routine is pretty gross.
615            // Namely, `set.contains` is linear in the size of the set,
616            // iteration over all conflicts is also obviously linear in
617            // the number of conflicting sets and then for each of those,
618            // we visit every possible pair of activated extra from above,
619            // which is quadratic in the total number of extras enabled. I
620            // believe the simplest improvement here, if it's necessary, is
621            // to adjust the `Conflicts` internals to own these sorts of
622            // checks. ---AG
623            for set in self.lock().conflicts().iter() {
624                for ((pkg1, extra1), (pkg2, extra2)) in
625                    activated_extras_set.iter().tuple_combinations()
626                {
627                    if set.contains(pkg1, *extra1) && set.contains(pkg2, *extra2) {
628                        return Err(LockErrorKind::ConflictingExtra {
629                            package1: (*pkg1).clone(),
630                            extra1: (*extra1).clone(),
631                            package2: (*pkg2).clone(),
632                            extra2: (*extra2).clone(),
633                        }
634                        .into());
635                    }
636                }
637            }
638        }
639
640        while let Some((package, extra)) = queue.pop_front() {
641            let deps = if let Some(extra) = extra {
642                Either::Left(
643                    package
644                        .optional_dependencies
645                        .get(extra)
646                        .into_iter()
647                        .flatten(),
648                )
649            } else {
650                Either::Right(package.dependencies.iter())
651            };
652            for dep in deps {
653                if validate_conflicts && dep.complexified_marker.has_conflict_marker() {
654                    dependencies_for_conflict_validation.push((package, dep));
655                }
656                if !dep.complexified_marker.evaluate(
657                    marker_env,
658                    activated_projects.iter().copied(),
659                    activated_extras.iter().copied(),
660                    activated_groups.iter().copied(),
661                ) {
662                    continue;
663                }
664
665                let dep_dist = self.lock().find_by_id(&dep.package_id);
666
667                // Add the dependency to the graph.
668                let dep_index = match inverse.entry(&dep.package_id) {
669                    Entry::Vacant(entry) => {
670                        let index = petgraph.add_node(self.package_to_node(
671                            dep_dist,
672                            tags,
673                            build_options,
674                            install_options,
675                            marker_env,
676                        )?);
677                        entry.insert(index);
678                        index
679                    }
680                    Entry::Occupied(entry) => *entry.get(),
681                };
682
683                // Add the edge.
684                let index = inverse[&package.id];
685                petgraph.add_edge(
686                    index,
687                    dep_index,
688                    if let Some(extra) = extra {
689                        Edge::Optional(extra.clone())
690                    } else {
691                        Edge::Prod
692                    },
693                );
694
695                // Push its dependencies on the queue.
696                if seen.insert((&dep.package_id, None)) {
697                    queue.push_back((dep_dist, None));
698                }
699                for extra in &dep.extra {
700                    if seen.insert((&dep.package_id, Some(extra))) {
701                        queue.push_back((dep_dist, Some(extra)));
702                    }
703                }
704            }
705        }
706
707        // Evaluate conflict markers from concrete roots, not from workspace members that depend on
708        // them. Reject markers that still depend on conflict items outside the resulting subgraph.
709        if !dependencies_for_conflict_validation.is_empty() {
710            let subgraph_packages = inverse
711                .keys()
712                .map(|package_id| &package_id.name)
713                .collect::<FxHashSet<_>>();
714
715            // The environment and conflict state are shared by every dependency, so repeated
716            // markers have the same result.
717            let mut validated_markers = FxHashSet::default();
718            for (package, dependency) in dependencies_for_conflict_validation {
719                if !validated_markers.insert(dependency.complexified_marker) {
720                    continue;
721                }
722                let mut marker = dependency.complexified_marker;
723                for item in self.lock().conflicts().iter().flat_map(ConflictSet::iter) {
724                    if !subgraph_packages.contains(item.package()) {
725                        continue;
726                    }
727
728                    let active = match item.kind() {
729                        ConflictKind::Project => activated_projects.contains(&item.package()),
730                        ConflictKind::Extra(extra) => {
731                            activated_extras.contains(&(item.package(), extra))
732                        }
733                        ConflictKind::Group(group) => {
734                            activated_groups.contains(&(item.package(), group))
735                        }
736                    };
737                    if active {
738                        marker.assume_conflict_item(item);
739                    } else {
740                        marker.assume_not_conflict_item(item);
741                    }
742                }
743
744                let conflict = marker.conflict_for_environment(marker_env);
745                // All in-subgraph conflict items were resolved above, so a non-constant marker
746                // still depends on a package outside the subgraph.
747                if !conflict.is_constant() {
748                    return Err(LockErrorKind::DependencyConflictOutsideSubgraph {
749                        package: package.id.clone(),
750                        dependency: dependency.package_id.clone(),
751                    }
752                    .into());
753                }
754            }
755        }
756
757        Ok(Resolution::new(petgraph))
758    }
759}
760
761impl<'lock, T> InstallableExt<'lock> for T where T: Installable<'lock> + ?Sized {}
762
763/// An [`Installable`] adapter for materializing concrete packages directly from a [`Lock`].
764struct LockedPackages<'lock> {
765    lock: &'lock Lock,
766    install_path: &'lock Path,
767    project_name: Option<&'lock PackageName>,
768}
769
770impl<'lock> Installable<'lock> for LockedPackages<'lock> {
771    fn install_path(&self) -> &'lock Path {
772        self.install_path
773    }
774
775    fn lock(&self) -> &'lock Lock {
776        self.lock
777    }
778
779    fn roots(&self) -> impl Iterator<Item = &PackageName> {
780        std::iter::empty()
781    }
782
783    fn project_name(&self) -> Option<&PackageName> {
784        self.project_name
785    }
786}
787
788impl Lock {
789    /// Materialize the exact dependency subgraph reachable from concrete locked `roots`.
790    ///
791    /// Each root must be a [`Package`] from this lock. Unlike [`Installable::to_resolution`], this
792    /// method does not include requirements or dependency groups attached directly to the lock
793    /// manifest. Extras and dependency groups on the concrete roots are still included according
794    /// to `extras` and `groups`.
795    ///
796    /// Conflict-marker evaluation starts from `roots` and their requested `extras` and `groups`,
797    /// not from workspace members that depend on those roots. The method returns an error if a
798    /// dependency marker still depends on a conflict item outside the resulting subgraph. Use
799    /// [`Installable::to_resolution`] when materializing an existing lock target.
800    ///
801    /// `project_name` identifies the project for project-specific [`InstallOptions`] filters, if
802    /// applicable. Callers are responsible for selecting roots that apply to `marker_env`.
803    pub fn to_resolution<'lock>(
804        &'lock self,
805        install_path: &'lock Path,
806        roots: impl IntoIterator<Item = &'lock Package>,
807        project_name: Option<&'lock PackageName>,
808        marker_env: &ResolverMarkerEnvironment,
809        tags: &Tags,
810        extras: &ExtrasSpecificationWithDefaults,
811        groups: &DependencyGroupsWithDefaults,
812        build_options: &BuildOptions,
813        install_options: &InstallOptions,
814    ) -> Result<Resolution, LockError> {
815        let mut seen = FxHashSet::default();
816        let mut concrete_roots = Vec::new();
817        for root in roots {
818            let Some(index) = self.by_id.get(&root.id) else {
819                return Err(LockErrorKind::RootPackageMissingFromLock {
820                    id: root.id.clone(),
821                }
822                .into());
823            };
824            if seen.insert(&root.id) {
825                let Some(root) = self.packages.get(*index) else {
826                    return Err(LockErrorKind::RootPackageMissingFromLock {
827                        id: root.id.clone(),
828                    }
829                    .into());
830                };
831                concrete_roots.push(root);
832            }
833        }
834
835        LockedPackages {
836            lock: self,
837            install_path,
838            project_name,
839        }
840        .to_resolution_from_packages(
841            &concrete_roots,
842            false,
843            marker_env,
844            tags,
845            extras,
846            groups,
847            build_options,
848            install_options,
849        )
850    }
851}
852
853#[cfg(test)]
854mod tests {
855    use std::cell::Cell;
856    use std::sync::LazyLock;
857
858    use petgraph::visit::EdgeRef;
859    use uv_configuration::{DependencyGroups, ExtrasSpecification};
860    use uv_distribution_types::Name;
861    use uv_normalize::{DefaultExtras, DefaultGroups};
862    use uv_pep508::{MarkerEnvironment, MarkerEnvironmentBuilder};
863    use uv_platform_tags::{Arch, Os, Platform, TagsOptions};
864    use uv_warnings::anstream;
865
866    use super::*;
867
868    static TAGS: LazyLock<Tags> = LazyLock::new(|| {
869        Tags::from_env(
870            &Platform::new(
871                Os::Macos {
872                    major: 14,
873                    minor: 0,
874                },
875                Arch::Aarch64,
876            ),
877            (3, 11),
878            "cpython",
879            (3, 11),
880            TagsOptions::default(),
881        )
882        .expect("valid tags")
883    });
884
885    static DARWIN_MARKERS: LazyLock<ResolverMarkerEnvironment> =
886        LazyLock::new(|| ResolverMarkerEnvironment::from(marker_environment("darwin", "Darwin")));
887
888    static LINUX_MARKERS: LazyLock<ResolverMarkerEnvironment> =
889        LazyLock::new(|| ResolverMarkerEnvironment::from(marker_environment("linux", "Linux")));
890
891    fn marker_environment(
892        sys_platform: &'static str,
893        platform_system: &'static str,
894    ) -> MarkerEnvironment {
895        MarkerEnvironment::try_from(MarkerEnvironmentBuilder {
896            implementation_name: "cpython",
897            implementation_version: "3.11.5",
898            os_name: "posix",
899            platform_machine: "arm64",
900            platform_python_implementation: "CPython",
901            platform_release: "23.0.0",
902            platform_system,
903            platform_version: "test",
904            python_full_version: "3.11.5",
905            python_version: "3.11",
906            sys_platform,
907        })
908        .expect("valid marker environment")
909    }
910
911    fn lock() -> Lock {
912        toml::from_str(
913            r#"
914version = 1
915revision = 3
916requires-python = ">=3.11"
917resolution-markers = [
918    "sys_platform == 'darwin'",
919    "sys_platform != 'darwin'",
920]
921
922[manifest]
923requirements = [{ name = "unrelated" }]
924
925[[package]]
926name = "dev-dependency"
927version = "1.0.0"
928source = { registry = "https://example.com/simple" }
929sdist = { url = "https://example.com/dev_dependency-1.0.0.tar.gz", hash = "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
930
931[[package]]
932name = "forked"
933version = "1.0.0"
934source = { registry = "https://example.com/simple" }
935resolution-markers = ["sys_platform == 'darwin'"]
936sdist = { url = "https://example.com/forked-1.0.0.tar.gz", hash = "sha256:2222222222222222222222222222222222222222222222222222222222222222" }
937
938[[package]]
939name = "forked"
940version = "2.0.0"
941source = { registry = "https://example.com/simple" }
942resolution-markers = ["sys_platform != 'darwin'"]
943sdist = { url = "https://example.com/forked-2.0.0.tar.gz", hash = "sha256:3333333333333333333333333333333333333333333333333333333333333333" }
944
945[[package]]
946name = "optional-dependency"
947version = "1.0.0"
948source = { registry = "https://example.com/simple" }
949sdist = { url = "https://example.com/optional_dependency-1.0.0.tar.gz", hash = "sha256:4444444444444444444444444444444444444444444444444444444444444444" }
950
951[[package]]
952name = "root-a"
953version = "1.0.0"
954source = { registry = "https://example.com/simple" }
955dependencies = [
956    { name = "forked", version = "1.0.0", source = { registry = "https://example.com/simple" }, marker = "sys_platform == 'darwin'" },
957    { name = "forked", version = "2.0.0", source = { registry = "https://example.com/simple" }, marker = "sys_platform != 'darwin'" },
958    { name = "shared" },
959]
960sdist = { url = "https://example.com/root_a-1.0.0.tar.gz", hash = "sha256:5555555555555555555555555555555555555555555555555555555555555555" }
961
962[package.optional-dependencies]
963feature = [{ name = "optional-dependency" }]
964
965[package.dependency-groups]
966dev = [{ name = "dev-dependency" }]
967
968[package.metadata]
969provides-extras = ["feature"]
970
971[[package]]
972name = "root-b"
973version = "1.0.0"
974source = { registry = "https://example.com/simple" }
975dependencies = [{ name = "shared" }]
976sdist = { url = "https://example.com/root_b-1.0.0.tar.gz", hash = "sha256:6666666666666666666666666666666666666666666666666666666666666666" }
977
978[[package]]
979name = "shared"
980version = "1.0.0"
981source = { registry = "https://example.com/simple" }
982sdist = { url = "https://example.com/shared-1.0.0.tar.gz", hash = "sha256:7777777777777777777777777777777777777777777777777777777777777777" }
983
984[[package]]
985name = "unrelated"
986version = "1.0.0"
987source = { registry = "https://example.com/simple" }
988sdist = { url = "https://example.com/unrelated-1.0.0.tar.gz", hash = "sha256:8888888888888888888888888888888888888888888888888888888888888888" }
989"#,
990        )
991        .expect("valid lock")
992    }
993
994    fn conflict_lock() -> Lock {
995        toml::from_str(
996            r#"
997version = 1
998revision = 3
999requires-python = ">=3.11"
1000conflicts = [
1001    [
1002        { package = "tool", extra = "cpu" },
1003        { package = "tool", extra = "gpu" },
1004    ],
1005    [
1006        { package = "project", extra = "foo" },
1007        { package = "project", extra = "bar" },
1008    ],
1009]
1010
1011[[package]]
1012name = "contextual-dependency"
1013version = "1.0.0"
1014source = { registry = "https://example.com/simple" }
1015sdist = { url = "https://example.com/contextual_dependency-1.0.0.tar.gz", hash = "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1016
1017[[package]]
1018name = "contextual-tool"
1019version = "1.0.0"
1020source = { registry = "https://example.com/simple" }
1021dependencies = [
1022    { name = "contextual-dependency", marker = "sys_platform == 'linux' or (sys_platform == 'darwin' and extra == 'extra-7-project-foo')" },
1023]
1024sdist = { url = "https://example.com/contextual_tool-1.0.0.tar.gz", hash = "sha256:2222222222222222222222222222222222222222222222222222222222222222" }
1025
1026[[package]]
1027name = "cpu-backend"
1028version = "1.0.0"
1029source = { registry = "https://example.com/simple" }
1030sdist = { url = "https://example.com/cpu_backend-1.0.0.tar.gz", hash = "sha256:3333333333333333333333333333333333333333333333333333333333333333" }
1031
1032[[package]]
1033name = "gpu-backend"
1034version = "1.0.0"
1035source = { registry = "https://example.com/simple" }
1036sdist = { url = "https://example.com/gpu_backend-1.0.0.tar.gz", hash = "sha256:4444444444444444444444444444444444444444444444444444444444444444" }
1037
1038[[package]]
1039name = "project"
1040version = "1.0.0"
1041source = { registry = "https://example.com/simple" }
1042sdist = { url = "https://example.com/project-1.0.0.tar.gz", hash = "sha256:5555555555555555555555555555555555555555555555555555555555555555" }
1043
1044[package.optional-dependencies]
1045foo = []
1046bar = []
1047
1048[package.metadata]
1049provides-extras = ["foo", "bar"]
1050
1051[[package]]
1052name = "runtime"
1053version = "1.0.0"
1054source = { registry = "https://example.com/simple" }
1055dependencies = [
1056    { name = "cpu-backend", marker = "extra == 'extra-4-tool-cpu'" },
1057    { name = "gpu-backend", marker = "extra == 'extra-4-tool-gpu'" },
1058]
1059sdist = { url = "https://example.com/runtime-1.0.0.tar.gz", hash = "sha256:6666666666666666666666666666666666666666666666666666666666666666" }
1060
1061[[package]]
1062name = "tool"
1063version = "1.0.0"
1064source = { registry = "https://example.com/simple" }
1065dependencies = [{ name = "runtime" }]
1066sdist = { url = "https://example.com/tool-1.0.0.tar.gz", hash = "sha256:7777777777777777777777777777777777777777777777777777777777777777" }
1067
1068[package.optional-dependencies]
1069cpu = []
1070gpu = []
1071
1072[package.metadata]
1073provides-extras = ["cpu", "gpu"]
1074"#,
1075        )
1076        .expect("valid lock")
1077    }
1078
1079    fn package<'lock>(lock: &'lock Lock, name: &str, version: &str) -> &'lock Package {
1080        lock.packages()
1081            .iter()
1082            .find(|package| {
1083                package.name().as_ref() == name
1084                    && package
1085                        .version()
1086                        .is_some_and(|package_version| package_version.to_string() == version)
1087            })
1088            .expect("locked package")
1089    }
1090
1091    fn materialize(
1092        lock: &Lock,
1093        roots: &[&Package],
1094        marker_env: &ResolverMarkerEnvironment,
1095    ) -> Resolution {
1096        let extras = ExtrasSpecification::from_all_extras().with_defaults(DefaultExtras::default());
1097        let groups = DependencyGroups::from_all_groups().with_defaults(DefaultGroups::default());
1098        lock.to_resolution(
1099            Path::new("."),
1100            roots.iter().copied(),
1101            None,
1102            marker_env,
1103            &TAGS,
1104            &extras,
1105            &groups,
1106            &BuildOptions::default(),
1107            &InstallOptions::default(),
1108        )
1109        .expect("valid resolution")
1110    }
1111
1112    fn materialize_with_extras(
1113        lock: &Lock,
1114        roots: &[&Package],
1115        marker_env: &ResolverMarkerEnvironment,
1116        extras: &ExtrasSpecification,
1117    ) -> Result<Resolution, LockError> {
1118        let extras = extras.with_defaults(DefaultExtras::default());
1119        let groups = DependencyGroupsWithDefaults::none();
1120        lock.to_resolution(
1121            Path::new("."),
1122            roots.iter().copied(),
1123            None,
1124            marker_env,
1125            &TAGS,
1126            &extras,
1127            &groups,
1128            &BuildOptions::default(),
1129            &InstallOptions::default(),
1130        )
1131    }
1132
1133    struct OverridingInstallable<'lock> {
1134        lock: &'lock Lock,
1135        root_name: &'lock PackageName,
1136        package_to_node_calls: Cell<usize>,
1137    }
1138
1139    impl<'lock> Installable<'lock> for OverridingInstallable<'lock> {
1140        fn install_path(&self) -> &'lock Path {
1141            Path::new(".")
1142        }
1143
1144        fn lock(&self) -> &'lock Lock {
1145            self.lock
1146        }
1147
1148        fn roots(&self) -> impl Iterator<Item = &PackageName> {
1149            std::iter::once(self.root_name)
1150        }
1151
1152        fn project_name(&self) -> Option<&PackageName> {
1153            None
1154        }
1155
1156        fn package_to_node(
1157            &self,
1158            _package: &Package,
1159            _tags: &Tags,
1160            _build_options: &BuildOptions,
1161            _install_options: &InstallOptions,
1162            _marker_env: &ResolverMarkerEnvironment,
1163        ) -> Result<Node, LockError> {
1164            self.package_to_node_calls
1165                .set(self.package_to_node_calls.get() + 1);
1166            Ok(Node::Root)
1167        }
1168    }
1169
1170    fn graph_snapshot(resolution: &Resolution) -> (Vec<String>, Vec<String>) {
1171        let graph = resolution.graph();
1172        let labels = graph
1173            .node_weights()
1174            .map(|node| match node {
1175                Node::Root => "root".to_string(),
1176                Node::Dist {
1177                    dist,
1178                    hashes,
1179                    install,
1180                } => format!(
1181                    "{}=={} (install: {install}, hashes: {})",
1182                    dist.name(),
1183                    dist.version()
1184                        .map(ToString::to_string)
1185                        .unwrap_or_else(|| "<dynamic>".to_string()),
1186                    hashes.iter().map(ToString::to_string).join(", ")
1187                ),
1188            })
1189            .collect::<Vec<_>>();
1190        let mut nodes = labels.clone();
1191        nodes.sort_unstable();
1192        let mut edges = graph
1193            .edge_references()
1194            .map(|edge| {
1195                format!(
1196                    "{} --{:?}--> {}",
1197                    labels[edge.source().index()],
1198                    edge.weight(),
1199                    labels[edge.target().index()]
1200                )
1201            })
1202            .collect::<Vec<_>>();
1203        edges.sort_unstable();
1204        (nodes, edges)
1205    }
1206
1207    #[test]
1208    fn materializes_multiple_concrete_roots_with_shared_dependencies() {
1209        let lock = lock();
1210        let resolution = materialize(
1211            &lock,
1212            &[
1213                package(&lock, "root-a", "1.0.0"),
1214                package(&lock, "root-b", "1.0.0"),
1215            ],
1216            &DARWIN_MARKERS,
1217        );
1218
1219        insta::with_settings!({
1220            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1221        }, {
1222            insta::assert_debug_snapshot!(graph_snapshot(&resolution), @r#"
1223        (
1224            [
1225                "dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1226                "forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1227                "optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1228                "root",
1229                "root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1230                "root-b==1.0.0 (install: true, hashes: sha256:[HASH])",
1231                "shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1232            ],
1233            [
1234                "root --Prod--> root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1235                "root --Prod--> root-b==1.0.0 (install: true, hashes: sha256:[HASH])",
1236                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Dev(GroupName(\"dev\"))--> dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1237                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Optional(ExtraName(\"feature\"))--> optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1238                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1239                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1240                "root-b==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1241            ],
1242        )
1243            "#);
1244        });
1245    }
1246
1247    #[test]
1248    fn materializes_the_selected_universal_lock_fork() {
1249        let lock = lock();
1250        let root = package(&lock, "root-a", "1.0.0");
1251        let darwin = materialize(&lock, &[root], &DARWIN_MARKERS);
1252        let linux = materialize(&lock, &[root], &LINUX_MARKERS);
1253        let concrete_fork =
1254            materialize(&lock, &[package(&lock, "forked", "1.0.0")], &DARWIN_MARKERS);
1255
1256        insta::with_settings!({
1257            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1258        }, {
1259            insta::assert_debug_snapshot!(graph_snapshot(&darwin), @r#"
1260        (
1261            [
1262                "dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1263                "forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1264                "optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1265                "root",
1266                "root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1267                "shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1268            ],
1269            [
1270                "root --Prod--> root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1271                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Dev(GroupName(\"dev\"))--> dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1272                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Optional(ExtraName(\"feature\"))--> optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1273                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1274                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1275            ],
1276        )
1277        "#);
1278            insta::assert_debug_snapshot!(graph_snapshot(&linux), @r#"
1279        (
1280            [
1281                "dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1282                "forked==2.0.0 (install: true, hashes: sha256:[HASH])",
1283                "optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1284                "root",
1285                "root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1286                "shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1287            ],
1288            [
1289                "root --Prod--> root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
1290                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Dev(GroupName(\"dev\"))--> dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1291                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Optional(ExtraName(\"feature\"))--> optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1292                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> forked==2.0.0 (install: true, hashes: sha256:[HASH])",
1293                "root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
1294            ],
1295        )
1296        "#);
1297            insta::assert_debug_snapshot!(graph_snapshot(&concrete_fork), @r#"
1298        (
1299            [
1300                "forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1301                "root",
1302            ],
1303            [
1304                "root --Prod--> forked==1.0.0 (install: true, hashes: sha256:[HASH])",
1305            ],
1306        )
1307            "#);
1308        });
1309    }
1310
1311    #[test]
1312    fn materializes_conflicting_extras_within_the_synthetic_root() {
1313        let lock = conflict_lock();
1314        let extras =
1315            ExtrasSpecification::from_extra(vec!["cpu".parse().expect("valid extra name")]);
1316        let resolution = materialize_with_extras(
1317            &lock,
1318            &[package(&lock, "tool", "1.0.0")],
1319            &DARWIN_MARKERS,
1320            &extras,
1321        )
1322        .expect("conflict markers are resolved within the subgraph");
1323
1324        insta::with_settings!({
1325            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1326        }, {
1327            insta::assert_debug_snapshot!(graph_snapshot(&resolution), @r#"
1328        (
1329            [
1330                "cpu-backend==1.0.0 (install: true, hashes: sha256:[HASH])",
1331                "root",
1332                "runtime==1.0.0 (install: true, hashes: sha256:[HASH])",
1333                "tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1334            ],
1335            [
1336                "root --Prod--> tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1337                "runtime==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> cpu-backend==1.0.0 (install: true, hashes: sha256:[HASH])",
1338                "tool==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> runtime==1.0.0 (install: true, hashes: sha256:[HASH])",
1339            ],
1340        )
1341        "#);
1342        });
1343    }
1344
1345    #[test]
1346    fn rejects_conflicts_outside_the_synthetic_root() {
1347        let lock = conflict_lock();
1348        let root = package(&lock, "contextual-tool", "1.0.0");
1349        let extras = ExtrasSpecification::default();
1350
1351        let error = materialize_with_extras(&lock, &[root], &DARWIN_MARKERS, &extras)
1352            .expect_err("Darwin dependency depends on the project extra");
1353        let error = error.to_string();
1354        let error = anstream::adapter::strip_str(&error);
1355        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");
1356
1357        let linux = materialize_with_extras(&lock, &[root], &LINUX_MARKERS, &extras)
1358            .expect("the dependency is unconditional on Linux");
1359        insta::with_settings!({
1360            filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
1361        }, {
1362            insta::assert_debug_snapshot!(graph_snapshot(&linux), @r#"
1363        (
1364            [
1365                "contextual-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1366                "contextual-tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1367                "root",
1368            ],
1369            [
1370                "contextual-tool==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> contextual-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
1371                "root --Prod--> contextual-tool==1.0.0 (install: true, hashes: sha256:[HASH])",
1372            ],
1373        )
1374        "#);
1375        });
1376    }
1377
1378    #[test]
1379    fn installable_to_resolution_preserves_node_overrides() {
1380        let mut lock = lock();
1381        lock.manifest.requirements.clear();
1382        let target = OverridingInstallable {
1383            root_name: package(&lock, "root-a", "1.0.0").name(),
1384            lock: &lock,
1385            package_to_node_calls: Cell::new(0),
1386        };
1387        let extras = ExtrasSpecification::from_all_extras().with_defaults(DefaultExtras::default());
1388        let groups = DependencyGroups::from_all_groups().with_defaults(DefaultGroups::default());
1389
1390        target
1391            .to_resolution(
1392                &DARWIN_MARKERS,
1393                &TAGS,
1394                &extras,
1395                &groups,
1396                &BuildOptions::default(),
1397                &InstallOptions::default(),
1398            )
1399            .expect("valid resolution");
1400
1401        assert!(target.package_to_node_calls.get() > 0);
1402    }
1403}