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
35fn 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 fn install_path(&self) -> &'lock Path;
64
65 fn lock(&self) -> &'lock Lock;
67
68 fn roots(&self) -> impl Iterator<Item = &PackageName>;
70
71 fn project_name(&self) -> Option<&PackageName>;
73
74 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 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 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 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
183trait InstallableExt<'lock>: Installable<'lock> {
185 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 if has_conflicts {
224 for dist in roots.iter().copied() {
225 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 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 let mut initialized_roots = vec![];
246 for dist in roots.iter().copied() {
247 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 petgraph.add_edge(root, index, Edge::Prod);
257
258 initialized_roots.push((dist, index));
260 }
261
262 for (dist, index) in initialized_roots {
264 if groups.prod() {
265 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 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 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 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 Edge::Dev(group.clone()),
353 );
354
355 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 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 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 petgraph.add_edge(root, index, Edge::Prod);
406
407 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 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 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 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 petgraph.add_edge(root, index, Edge::Dev(group.clone()));
492
493 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 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 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 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 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 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 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 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 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 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 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
763struct 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 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}