1use std::fmt;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use anyhow::{Result, bail};
6use owo_colors::OwoColorize;
7use tracing::{debug, warn};
8
9use uv_cache::{Cache, CacheBucket, WheelCache};
10use uv_cache_info::Timestamp;
11use uv_configuration::{BuildOptions, Reinstall};
12use uv_distribution::{
13 BuiltWheelIndex, HttpArchivePointer, PathArchivePointer, RegistryWheelIndex,
14};
15use uv_distribution_filename::WheelFilename;
16use uv_distribution_types::{
17 BuiltDist, CachedDirectUrlDist, CachedDist, ConfigSettings, Dist, Error, ExtraBuildRequires,
18 ExtraBuildVariables, Hashed, IndexLocations, InstalledDist, Name, PackageConfigSettings,
19 RequirementSource, Resolution, ResolvedDist, SourceDist,
20};
21use uv_fs::Simplified;
22use uv_normalize::PackageName;
23use uv_platform_tags::{AbiTag, IncompatibleTag, LanguageTag, PlatformTag, TagCompatibility, Tags};
24use uv_pypi_types::VerbatimParsedUrl;
25use uv_python::PythonEnvironment;
26use uv_redacted::DisplaySafeUrl;
27use uv_types::HashStrategy;
28
29use crate::satisfies::RequirementSatisfaction;
30use crate::{InstallationStrategy, SitePackages};
31
32#[derive(Debug)]
34pub struct IncompatibleWheelError {
35 kind: IncompatibleWheelKind,
37 compatibility_hint: Option<IncompatibleWheelHint>,
39}
40
41#[derive(Debug)]
42enum IncompatibleWheelKind {
43 Url(DisplaySafeUrl),
44 Path(PathBuf),
45}
46
47impl fmt::Display for IncompatibleWheelKind {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 match self {
50 Self::Url(url) => write!(f, "URL ({url})"),
51 Self::Path(path) => write!(f, "path ({})", path.user_display()),
52 }
53 }
54}
55
56#[derive(Debug)]
58enum IncompatibleWheelHint {
59 Python {
61 wheel_tags: Vec<LanguageTag>,
62 current: Option<LanguageTag>,
63 },
64 Abi {
66 wheel_tags: Vec<AbiTag>,
67 current: Option<AbiTag>,
68 },
69 FreethreadedAbi {
71 wheel_tags: Vec<AbiTag>,
72 current: Option<AbiTag>,
73 },
74 Platform {
76 wheel_tags: Vec<PlatformTag>,
77 current: Option<PlatformTag>,
78 },
79}
80
81impl fmt::Display for IncompatibleWheelHint {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 match self {
84 Self::Python {
85 wheel_tags,
86 current,
87 } => {
88 if let Some(current) = current {
89 write!(
90 f,
91 "The wheel is compatible with {}, but you're using {}",
92 format_language_tags(wheel_tags),
93 format_language_tag(*current),
94 )
95 } else {
96 write!(f, "The wheel requires {}", format_language_tags(wheel_tags))
97 }
98 }
99 Self::Abi {
100 wheel_tags,
101 current,
102 } => {
103 if let Some(current) = current {
104 write!(
105 f,
106 "The wheel is compatible with {}, but you're using {}",
107 format_abi_tags(wheel_tags),
108 format_abi_tag(*current),
109 )
110 } else {
111 write!(f, "The wheel requires {}", format_abi_tags(wheel_tags))
112 }
113 }
114 Self::FreethreadedAbi {
115 wheel_tags,
116 current,
117 } => {
118 let current_display = if let Some(current) = current {
119 format_abi_tag(*current)
120 } else {
121 "free-threaded Python".to_string()
122 };
123 let wheel_display = wheel_tags
124 .iter()
125 .map(|tag| match tag {
126 AbiTag::Abi3 => format!("the stable ABI (`{}`)", tag.cyan()),
127 _ => {
128 if let Some(pretty) = tag.pretty() {
129 format!("the {} ABI (`{}`)", pretty.cyan(), tag.cyan())
130 } else {
131 format!("`{}`", tag.cyan())
132 }
133 }
134 })
135 .collect::<Vec<_>>()
136 .join(", ");
137 write!(
138 f,
139 "You're using {current_display}, but the wheel was built for {wheel_display}, which requires a GIL-enabled interpreter"
140 )
141 }
142 Self::Platform {
143 wheel_tags,
144 current,
145 } => {
146 if let Some(current) = current {
147 write!(
148 f,
149 "The wheel is compatible with {}, but you're on {}",
150 format_platform_tags(wheel_tags),
151 format_platform_tag(current),
152 )
153 } else {
154 write!(f, "The wheel requires {}", format_platform_tags(wheel_tags))
155 }
156 }
157 }
158 }
159}
160
161fn format_language_tag(tag: LanguageTag) -> String {
163 if let Some(pretty) = tag.pretty() {
164 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
165 } else {
166 format!("`{}`", tag.cyan())
167 }
168}
169
170fn format_language_tags(tags: &[LanguageTag]) -> String {
172 tags.iter()
173 .map(|tag| format_language_tag(*tag))
174 .collect::<Vec<_>>()
175 .join(", ")
176}
177
178fn format_abi_tag(tag: AbiTag) -> String {
180 if let Some(pretty) = tag.pretty() {
181 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
182 } else {
183 format!("`{}`", tag.cyan())
184 }
185}
186
187fn format_abi_tags(tags: &[AbiTag]) -> String {
189 tags.iter()
190 .map(|tag| format_abi_tag(*tag))
191 .collect::<Vec<_>>()
192 .join(", ")
193}
194
195fn format_platform_tag(tag: &PlatformTag) -> String {
197 if let Some(pretty) = tag.pretty() {
198 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
199 } else {
200 format!("`{}`", tag.cyan())
201 }
202}
203
204fn format_platform_tags(tags: &[PlatformTag]) -> String {
206 tags.iter()
207 .map(format_platform_tag)
208 .collect::<Vec<_>>()
209 .join(", ")
210}
211
212impl fmt::Display for IncompatibleWheelError {
213 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214 write!(
215 f,
216 "A {} dependency is incompatible with the current platform",
217 self.kind,
218 )
219 }
220}
221
222impl std::error::Error for IncompatibleWheelError {}
223
224impl uv_errors::Hint for IncompatibleWheelError {
225 fn hints(&self) -> uv_errors::Hints<'_> {
226 if let Some(hint) = &self.compatibility_hint {
227 uv_errors::Hints::from(hint.to_string())
228 } else {
229 uv_errors::Hints::none()
230 }
231 }
232}
233
234#[derive(Debug)]
236pub struct Planner<'a> {
237 resolution: &'a Resolution,
238}
239
240impl<'a> Planner<'a> {
241 pub fn new(resolution: &'a Resolution) -> Self {
243 Self { resolution }
244 }
245
246 pub fn build(
258 self,
259 mut site_packages: SitePackages,
260 installation: InstallationStrategy,
261 reinstall: &Reinstall,
262 build_options: &BuildOptions,
263 hasher: &HashStrategy,
264 index_locations: &IndexLocations,
265 config_settings: &ConfigSettings,
266 config_settings_package: &PackageConfigSettings,
267 extra_build_requires: &ExtraBuildRequires,
268 extra_build_variables: &ExtraBuildVariables,
269 cache: &Cache,
270 venv: &PythonEnvironment,
271 tags: &Tags,
272 ) -> Result<Plan> {
273 let mut registry_index = RegistryWheelIndex::new(
275 cache,
276 tags,
277 index_locations,
278 hasher,
279 config_settings,
280 config_settings_package,
281 extra_build_requires,
282 extra_build_variables,
283 );
284 let built_index = BuiltWheelIndex::new(
285 cache,
286 tags,
287 hasher,
288 config_settings,
289 config_settings_package,
290 extra_build_requires,
291 extra_build_variables,
292 );
293
294 let mut cached = vec![];
295 let mut remote = vec![];
296 let mut reinstalls = vec![];
297 let mut extraneous = vec![];
298
299 for dist in self.resolution.distributions() {
311 let reinstall = reinstall.contains_package(dist.name())
313 || dist
314 .source_tree()
315 .is_some_and(|source_tree| reinstall.contains_path(source_tree));
316
317 let no_binary = build_options.no_binary_package(dist.name());
319 let no_build = build_options.no_build_package(dist.name());
320
321 let installed_dists = site_packages.remove_packages(dist.name());
323 if reinstall {
324 reinstalls.extend(installed_dists);
325 } else {
326 match installed_dists.as_slice() {
327 [] => {}
328 [installed] => {
329 let source = RequirementSource::from(dist);
330 match RequirementSatisfaction::check(
331 dist.name(),
332 installed,
333 &source,
334 dist.version(),
335 installation,
336 tags,
337 config_settings,
338 config_settings_package,
339 extra_build_requires,
340 extra_build_variables,
341 ) {
342 RequirementSatisfaction::Mismatch => {
343 debug!(
344 "Requirement installed, but mismatched:\n Installed: {installed:?}\n Requested: {source:?}"
345 );
346 }
347 RequirementSatisfaction::Satisfied => {
348 debug!("Requirement already installed: {installed}");
349 continue;
350 }
351 RequirementSatisfaction::OutOfDate => {
352 debug!("Requirement installed, but not fresh: {installed}");
353
354 if matches!(dist, ResolvedDist::Installed { .. }) {
366 warn!(
367 "Installed distribution was considered out-of-date, but returned by the resolver: {dist}"
368 );
369 continue;
370 }
371 }
372 RequirementSatisfaction::CacheInvalid => {
373 }
375 }
376 reinstalls.push(installed.clone());
377 }
378 _ => reinstalls.extend(installed_dists),
382 }
383 }
384
385 let ResolvedDist::Installable { dist, .. } = dist else {
386 unreachable!("Installed distribution could not be found in site-packages: {dist}");
387 };
388
389 if cache.must_revalidate_package(dist.name())
390 || dist
391 .source_tree()
392 .is_some_and(|source_tree| cache.must_revalidate_path(source_tree))
393 {
394 debug!("Must revalidate requirement: {}", dist.name());
395 remote.push(dist.clone());
396 continue;
397 }
398
399 match dist.as_ref() {
401 Dist::Built(BuiltDist::Registry(wheel)) => {
402 if let Some(distribution) = registry_index.wheel(wheel, no_build, no_binary) {
403 debug!("Registry requirement already cached: {distribution}");
404 cached.push(CachedDist::Registry(distribution.clone()));
405 continue;
406 }
407 }
408 Dist::Built(BuiltDist::DirectUrl(wheel)) => {
409 if !wheel.filename.is_compatible(tags) {
410 return Err(IncompatibleWheelError {
411 kind: IncompatibleWheelKind::Url(wheel.url.to_url()),
412 compatibility_hint: generate_wheel_compatibility_hint(
413 &wheel.filename,
414 tags,
415 ),
416 }
417 .into());
418 }
419
420 if no_binary {
421 bail!(
422 "A URL dependency points to a wheel which conflicts with `--no-binary`: {}",
423 wheel.url
424 );
425 }
426
427 let cache_entry = cache
430 .shard(
431 CacheBucket::Wheels,
432 WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
433 )
434 .entry(format!("{}.http", wheel.filename.cache_key()));
435
436 match HttpArchivePointer::read_from(&cache_entry) {
438 Ok(Some(pointer)) => {
439 let cache_info = pointer.to_cache_info();
440 let build_info = pointer.to_build_info();
441 let archive = pointer.into_archive();
442 if archive.satisfies(hasher.get(dist.as_ref())) {
443 let cached_dist = CachedDirectUrlDist {
444 filename: wheel.filename.clone(),
445 url: VerbatimParsedUrl {
446 parsed_url: wheel.to_parsed_url(),
447 verbatim: wheel.url.clone(),
448 },
449 hashes: archive.hashes,
450 cache_info,
451 build_info,
452 path: cache.archive(&archive.id).into_boxed_path(),
453 };
454
455 debug!("URL wheel requirement already cached: {cached_dist}");
456 cached.push(CachedDist::Url(cached_dist));
457 continue;
458 }
459 debug!(
460 "Cached URL wheel requirement does not match expected hash policy for: {wheel}"
461 );
462 }
463 Ok(None) => {}
464 Err(err) => {
465 debug!(
466 "Failed to deserialize cached URL wheel requirement for: {wheel} ({err})"
467 );
468 }
469 }
470 }
471 Dist::Built(BuiltDist::Path(wheel)) => {
472 if !wheel.install_path.exists() {
474 return Err(Error::NotFound(wheel.url.to_url()).into());
475 }
476
477 if !wheel.filename.is_compatible(tags) {
478 return Err(IncompatibleWheelError {
479 kind: IncompatibleWheelKind::Path(wheel.install_path.to_path_buf()),
480 compatibility_hint: generate_wheel_compatibility_hint(
481 &wheel.filename,
482 tags,
483 ),
484 }
485 .into());
486 }
487
488 if no_binary {
489 bail!(
490 "A path dependency points to a wheel which conflicts with `--no-binary`: {}",
491 wheel.url
492 );
493 }
494
495 let cache_entry = cache
498 .shard(
499 CacheBucket::Wheels,
500 WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
501 )
502 .entry(format!("{}.rev", wheel.filename.cache_key()));
503
504 match PathArchivePointer::read_from(&cache_entry) {
505 Ok(Some(pointer)) => match Timestamp::from_path(&wheel.install_path) {
506 Ok(timestamp) => {
507 if pointer.is_up_to_date(timestamp) {
508 let cache_info = pointer.to_cache_info();
509 let build_info = pointer.to_build_info();
510 let archive = pointer.into_archive();
511 if archive.satisfies(hasher.get(dist.as_ref())) {
512 let cached_dist = CachedDirectUrlDist {
513 filename: wheel.filename.clone(),
514 url: VerbatimParsedUrl {
515 parsed_url: wheel.to_parsed_url(),
516 verbatim: wheel.url.clone(),
517 },
518 hashes: archive.hashes,
519 cache_info,
520 build_info,
521 path: cache.archive(&archive.id).into_boxed_path(),
522 };
523 debug!(
524 "Path wheel requirement already cached: {cached_dist}"
525 );
526 cached.push(CachedDist::Url(cached_dist));
527 continue;
528 }
529 debug!(
530 "Cached path wheel requirement does not match expected hash policy for: {wheel}"
531 );
532 }
533 }
534 Err(err) => {
535 debug!("Failed to get timestamp for wheel {wheel} ({err})");
536 }
537 },
538 Ok(None) => {}
539 Err(err) => {
540 debug!(
541 "Failed to deserialize cached path wheel requirement for: {wheel} ({err})"
542 );
543 }
544 }
545 }
546 Dist::Built(BuiltDist::GitPath(wheel)) => {
547 if !wheel.filename.is_compatible(tags) {
548 bail!(
549 "A Git path dependency is incompatible with the current platform: {}",
550 wheel.install_path.user_display()
551 );
552 }
553
554 if no_binary {
555 bail!(
556 "A Git path dependency points to a wheel which conflicts with `--no-binary`: {}",
557 wheel.url
558 );
559 }
560
561 if let Some(git_sha) = wheel.git.precise() {
562 let cache_entry = cache
565 .shard(
566 CacheBucket::Wheels,
567 WheelCache::Git(&wheel.url, git_sha.as_short_str()).root(),
568 )
569 .entry(format!("{}.rev", wheel.filename.cache_key()));
570
571 if let Some(pointer) = PathArchivePointer::read_from(&cache_entry)? {
572 let cache_info = pointer.to_cache_info();
573 let build_info = pointer.to_build_info();
574 let archive = pointer.into_archive();
575 if archive.satisfies(hasher.get(dist.as_ref())) {
576 let cached_dist = CachedDirectUrlDist {
577 filename: wheel.filename.clone(),
578 url: VerbatimParsedUrl {
579 parsed_url: wheel.to_parsed_url(),
580 verbatim: wheel.url.clone(),
581 },
582 hashes: archive.hashes,
583 cache_info,
584 build_info,
585 path: cache.archive(&archive.id).into_boxed_path(),
586 };
587
588 debug!("Git wheel requirement already cached: {cached_dist}");
589 cached.push(CachedDist::Url(cached_dist));
590 continue;
591 }
592 }
593 }
594 }
595 Dist::Source(SourceDist::Registry(sdist)) => {
596 if let Some(distribution) = registry_index.source(sdist, no_build, no_binary) {
597 debug!("Registry requirement already cached: {distribution}");
598 cached.push(CachedDist::Registry(distribution.clone()));
599 continue;
600 }
601 }
602 Dist::Source(SourceDist::DirectUrl(sdist)) => {
603 match built_index.url(sdist) {
606 Ok(Some(wheel)) => {
607 if wheel.filename().name == sdist.name {
608 let cached_dist = wheel.into_url_dist(VerbatimParsedUrl {
609 parsed_url: sdist.to_parsed_url(),
610 verbatim: sdist.url.clone(),
611 });
612 debug!("URL source requirement already cached: {cached_dist}");
613 cached.push(CachedDist::Url(cached_dist));
614 continue;
615 }
616
617 warn!(
618 "Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
619 sdist,
620 wheel.filename()
621 );
622 }
623 Ok(None) => {}
624 Err(err) => {
625 debug!(
626 "Failed to deserialize cached wheel filename for: {sdist} ({err})"
627 );
628 }
629 }
630 }
631 Dist::Source(SourceDist::GitPath(sdist)) => {
632 if let Some(wheel) = built_index.git_path(sdist)? {
635 if wheel.filename().name == sdist.name {
636 let cached_dist = wheel.into_url_dist(VerbatimParsedUrl {
637 parsed_url: sdist.to_parsed_url(),
638 verbatim: sdist.url.clone(),
639 });
640 debug!("Git source requirement already cached: {cached_dist}");
641 cached.push(CachedDist::Url(cached_dist));
642 continue;
643 }
644
645 warn!(
646 "Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
647 sdist,
648 wheel.filename()
649 );
650 }
651 }
652 Dist::Source(SourceDist::GitDirectory(sdist)) => {
653 if let Some(wheel) = built_index.git_directory(sdist) {
656 if wheel.filename().name == sdist.name {
657 let cached_dist = wheel.into_url_dist(VerbatimParsedUrl {
658 parsed_url: sdist.to_parsed_url(),
659 verbatim: sdist.url.clone(),
660 });
661 debug!("Git source requirement already cached: {cached_dist}");
662 cached.push(CachedDist::Url(cached_dist));
663 continue;
664 }
665
666 warn!(
667 "Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
668 sdist,
669 wheel.filename()
670 );
671 }
672 }
673 Dist::Source(SourceDist::Path(sdist)) => {
674 if !sdist.install_path.exists() {
676 return Err(Error::NotFound(sdist.url.to_url()).into());
677 }
678
679 match built_index.path(sdist) {
682 Ok(Some(wheel)) => {
683 if wheel.filename().name == sdist.name {
684 let cached_dist = wheel.into_url_dist(VerbatimParsedUrl {
685 parsed_url: sdist.to_parsed_url(),
686 verbatim: sdist.url.clone(),
687 });
688 debug!("Path source requirement already cached: {cached_dist}");
689 cached.push(CachedDist::Url(cached_dist));
690 continue;
691 }
692
693 warn!(
694 "Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
695 sdist,
696 wheel.filename()
697 );
698 }
699 Ok(None) => {}
700 Err(err) => {
701 debug!(
702 "Failed to deserialize cached wheel filename for: {sdist} ({err})"
703 );
704 }
705 }
706 }
707 Dist::Source(SourceDist::Directory(sdist)) => {
708 if !sdist.install_path.exists() {
710 return Err(Error::NotFound(sdist.url.to_url()).into());
711 }
712
713 match built_index.directory(sdist) {
716 Ok(Some(wheel)) => {
717 if wheel.filename().name == sdist.name {
718 let cached_dist = wheel.into_url_dist(VerbatimParsedUrl {
719 parsed_url: sdist.to_parsed_url(),
720 verbatim: sdist.url.clone(),
721 });
722 debug!(
723 "Directory source requirement already cached: {cached_dist}"
724 );
725 cached.push(CachedDist::Url(cached_dist));
726 continue;
727 }
728
729 warn!(
730 "Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
731 sdist,
732 wheel.filename()
733 );
734 }
735 Ok(None) => {}
736 Err(err) => {
737 debug!(
738 "Failed to deserialize cached wheel filename for: {sdist} ({err})"
739 );
740 }
741 }
742 }
743 }
744
745 debug!("Identified uncached distribution: {dist}");
746 remote.push(dist.clone());
747 }
748
749 if site_packages.any() {
751 let seed_packages = !venv.cfg().is_ok_and(|cfg| cfg.is_uv() && !cfg.is_seed());
754 for dist_info in site_packages {
755 if seed_packages && is_seed_package(&dist_info, venv) {
756 debug!("Preserving seed package: {dist_info}");
757 continue;
758 }
759
760 debug!("Unnecessary package: {dist_info}");
761 extraneous.push(dist_info);
762 }
763 }
764
765 Ok(Plan {
766 cached,
767 remote,
768 reinstalls,
769 extraneous,
770 })
771 }
772}
773
774fn is_seed_package(dist_info: &InstalledDist, venv: &PythonEnvironment) -> bool {
776 if venv.interpreter().python_tuple() >= (3, 12) {
777 matches!(dist_info.name().as_ref(), "uv" | "pip")
778 } else {
779 matches!(
781 dist_info.name().as_ref(),
782 "pip" | "setuptools" | "wheel" | "uv"
783 )
784 }
785}
786
787fn generate_wheel_compatibility_hint(
789 filename: &WheelFilename,
790 tags: &Tags,
791) -> Option<IncompatibleWheelHint> {
792 let TagCompatibility::Incompatible(incompatible_tag) = filename.compatibility(tags) else {
793 return None;
794 };
795
796 match incompatible_tag {
797 IncompatibleTag::Python => Some(IncompatibleWheelHint::Python {
798 wheel_tags: filename.python_tags().to_vec(),
799 current: tags.python_tag(),
800 }),
801 IncompatibleTag::FreethreadedAbi => Some(IncompatibleWheelHint::FreethreadedAbi {
802 wheel_tags: filename.abi_tags().to_vec(),
803 current: tags.abi_tag(),
804 }),
805 IncompatibleTag::Abi => Some(IncompatibleWheelHint::Abi {
806 wheel_tags: filename.abi_tags().to_vec(),
807 current: tags.abi_tag(),
808 }),
809 IncompatibleTag::Platform => Some(IncompatibleWheelHint::Platform {
810 wheel_tags: filename.platform_tags().to_vec(),
811 current: tags.platform_tag().cloned(),
812 }),
813 _ => None,
814 }
815}
816
817#[derive(Debug, Default)]
818pub struct Plan {
819 pub cached: Vec<CachedDist>,
822
823 pub remote: Vec<Arc<Dist>>,
826
827 pub reinstalls: Vec<InstalledDist>,
830
831 pub extraneous: Vec<InstalledDist>,
834}
835
836impl Plan {
837 pub fn is_empty(&self) -> bool {
839 self.cached.is_empty()
840 && self.remote.is_empty()
841 && self.reinstalls.is_empty()
842 && self.extraneous.is_empty()
843 }
844
845 pub fn partition<F>(self, mut f: F) -> (Self, Self)
854 where
855 F: FnMut(&PackageName) -> bool,
856 {
857 let Self {
858 cached,
859 remote,
860 reinstalls,
861 extraneous,
862 } = self;
863
864 let (left_remote, right_remote) = remote
866 .into_iter()
867 .partition::<Vec<_>, _>(|dist| f(dist.name()));
868
869 let (left_reinstalls, right_reinstalls) = reinstalls
873 .into_iter()
874 .partition::<Vec<_>, _>(|dist| !right_remote.iter().any(|d| d.name() == dist.name()));
875
876 let (left_extraneous, right_extraneous) = if right_remote.is_empty() {
880 (extraneous, vec![])
881 } else {
882 (vec![], extraneous)
883 };
884
885 let (left_cached, right_cached) = (cached, vec![]);
887
888 let left_plan = Self {
890 cached: left_cached,
891 remote: left_remote,
892 reinstalls: left_reinstalls,
893 extraneous: left_extraneous,
894 };
895
896 let right_plan = Self {
899 cached: right_cached,
900 remote: right_remote,
901 reinstalls: right_reinstalls,
902 extraneous: right_extraneous,
903 };
904
905 (left_plan, right_plan)
906 }
907}
908
909#[cfg(test)]
910mod tests {
911 use super::*;
912 use std::str::FromStr;
913 use uv_platform_tags::{Arch, Os, Platform, TagsOptions};
914
915 #[test]
916 fn test_abi3_on_free_threaded_python_hint() {
917 let platform = Platform::new(
919 Os::Manylinux {
920 major: 2,
921 minor: 28,
922 },
923 Arch::X86_64,
924 );
925 let tags = Tags::from_env(
926 platform,
927 (3, 14), "cpython", (3, 14), TagsOptions {
931 manylinux_compatible: true,
932 gil_disabled: true,
933 debug_enabled: false,
934 is_cross: false,
935 },
936 )
937 .unwrap();
938
939 let filename =
941 WheelFilename::from_str("foo-1.0-cp37-abi3-manylinux_2_17_x86_64.whl").unwrap();
942
943 let hint = generate_wheel_compatibility_hint(&filename, &tags).unwrap();
945
946 let hint = hint.to_string();
947 let hint = anstream::adapter::strip_str(&hint);
948 insta::assert_snapshot!(hint, @"You're using free-threaded CPython 3.14 (`cp314t`), but the wheel was built for the stable ABI (`abi3`), which requires a GIL-enabled interpreter");
949 }
950
951 #[test]
952 fn test_gil_enabled_cpython_on_free_threaded_python_hint() {
953 let platform = Platform::new(
955 Os::Manylinux {
956 major: 2,
957 minor: 28,
958 },
959 Arch::X86_64,
960 );
961 let tags = Tags::from_env(
962 platform,
963 (3, 14), "cpython", (3, 14), TagsOptions {
967 manylinux_compatible: true,
968 gil_disabled: true,
969 debug_enabled: false,
970 is_cross: false,
971 },
972 )
973 .unwrap();
974
975 let filename =
977 WheelFilename::from_str("foo-1.0-cp314-cp314-manylinux_2_17_x86_64.whl").unwrap();
978
979 let hint = generate_wheel_compatibility_hint(&filename, &tags).unwrap();
981
982 let hint = hint.to_string();
983 let hint = anstream::adapter::strip_str(&hint);
984 insta::assert_snapshot!(hint, @"You're using free-threaded CPython 3.14 (`cp314t`), but the wheel was built for the CPython 3.14 ABI (`cp314`), which requires a GIL-enabled interpreter");
985 }
986
987 #[test]
988 fn test_abi3_on_regular_python_no_special_hint() {
989 let platform = Platform::new(
991 Os::Manylinux {
992 major: 2,
993 minor: 28,
994 },
995 Arch::X86_64,
996 );
997 let tags = Tags::from_env(
998 platform,
999 (3, 14), "cpython", (3, 14), TagsOptions {
1003 manylinux_compatible: true,
1004 gil_disabled: false,
1005 debug_enabled: false,
1006 is_cross: false,
1007 },
1008 )
1009 .unwrap();
1010
1011 let filename =
1013 WheelFilename::from_str("foo-1.0-cp37-abi3-manylinux_2_17_x86_64.whl").unwrap();
1014
1015 let hint = generate_wheel_compatibility_hint(&filename, &tags);
1017
1018 assert!(
1020 hint.is_none(),
1021 "Expected no hint (wheel should be compatible), got: {hint:?}"
1022 );
1023 }
1024}