uv_installer/plan.rs
1use std::sync::Arc;
2
3use anyhow::{Result, bail};
4use owo_colors::OwoColorize;
5use tracing::{debug, warn};
6
7use uv_cache::{Cache, CacheBucket, WheelCache};
8use uv_cache_info::Timestamp;
9use uv_configuration::{BuildOptions, Reinstall};
10use uv_distribution::{
11 BuiltWheelIndex, HttpArchivePointer, LocalArchivePointer, RegistryWheelIndex,
12};
13use uv_distribution_filename::WheelFilename;
14use uv_distribution_types::{
15 BuiltDist, CachedDirectUrlDist, CachedDist, ConfigSettings, Dist, Error, ExtraBuildRequires,
16 ExtraBuildVariables, Hashed, IndexLocations, InstalledDist, Name, PackageConfigSettings,
17 RequirementSource, Resolution, ResolvedDist, SourceDist,
18};
19use uv_fs::Simplified;
20use uv_normalize::PackageName;
21use uv_platform_tags::{AbiTag, IncompatibleTag, TagCompatibility, Tags};
22use uv_pypi_types::VerbatimParsedUrl;
23use uv_python::PythonEnvironment;
24use uv_types::HashStrategy;
25
26use crate::satisfies::RequirementSatisfaction;
27use crate::{InstallationStrategy, SitePackages};
28
29/// A planner to generate an [`Plan`] based on a set of requirements.
30#[derive(Debug)]
31pub struct Planner<'a> {
32 resolution: &'a Resolution,
33}
34
35impl<'a> Planner<'a> {
36 /// Set the requirements use in the [`Plan`].
37 pub fn new(resolution: &'a Resolution) -> Self {
38 Self { resolution }
39 }
40
41 /// Partition a set of requirements into those that should be linked from the cache, those that
42 /// need to be downloaded, and those that should be removed.
43 ///
44 /// The install plan will respect cache [`Freshness`]. Specifically, if refresh is enabled, the
45 /// plan will respect cache entries created after the current time (as per the [`Refresh`]
46 /// policy). Otherwise, entries will be ignored. The downstream distribution database may still
47 /// read those entries from the cache after revalidating them.
48 ///
49 /// The install plan will also respect the required hashes, such that it will never return a
50 /// cached distribution that does not match the required hash. Like pip, though, it _will_
51 /// return an _installed_ distribution that does not match the required hash.
52 pub fn build(
53 self,
54 mut site_packages: SitePackages,
55 installation: InstallationStrategy,
56 reinstall: &Reinstall,
57 build_options: &BuildOptions,
58 hasher: &HashStrategy,
59 index_locations: &IndexLocations,
60 config_settings: &ConfigSettings,
61 config_settings_package: &PackageConfigSettings,
62 extra_build_requires: &ExtraBuildRequires,
63 extra_build_variables: &ExtraBuildVariables,
64 cache: &Cache,
65 venv: &PythonEnvironment,
66 tags: &Tags,
67 ) -> Result<Plan> {
68 // Index all the already-downloaded wheels in the cache.
69 let mut registry_index = RegistryWheelIndex::new(
70 cache,
71 tags,
72 index_locations,
73 hasher,
74 config_settings,
75 config_settings_package,
76 extra_build_requires,
77 extra_build_variables,
78 );
79 let built_index = BuiltWheelIndex::new(
80 cache,
81 tags,
82 hasher,
83 config_settings,
84 config_settings_package,
85 extra_build_requires,
86 extra_build_variables,
87 );
88
89 let mut cached = vec![];
90 let mut remote = vec![];
91 let mut reinstalls = vec![];
92 let mut extraneous = vec![];
93
94 // TODO(charlie): There are a few assumptions here that are hard to spot:
95 //
96 // 1. Apparently, we never return direct URL distributions as [`ResolvedDist::Installed`].
97 // If you trace the resolver, we only ever return [`ResolvedDist::Installed`] if you go
98 // through the [`CandidateSelector`], and we only go through the [`CandidateSelector`]
99 // for registry distributions.
100 //
101 // 2. We expect any distribution returned as [`ResolvedDist::Installed`] to hit the
102 // "Requirement already installed" path (hence the `unreachable!`) a few lines below it.
103 // So, e.g., if a package is marked as `--reinstall`, we _expect_ that it's not passed in
104 // as [`ResolvedDist::Installed`] here.
105 for dist in self.resolution.distributions() {
106 // Check if the package should be reinstalled.
107 let reinstall = reinstall.contains_package(dist.name())
108 || dist
109 .source_tree()
110 .is_some_and(|source_tree| reinstall.contains_path(source_tree));
111
112 // Check if installation of a binary version of the package should be allowed.
113 let no_binary = build_options.no_binary_package(dist.name());
114 let no_build = build_options.no_build_package(dist.name());
115
116 // Determine whether the distribution is already installed.
117 let installed_dists = site_packages.remove_packages(dist.name());
118 if reinstall {
119 reinstalls.extend(installed_dists);
120 } else {
121 match installed_dists.as_slice() {
122 [] => {}
123 [installed] => {
124 let source = RequirementSource::from(dist);
125 match RequirementSatisfaction::check(
126 dist.name(),
127 installed,
128 &source,
129 dist.version(),
130 installation,
131 tags,
132 config_settings,
133 config_settings_package,
134 extra_build_requires,
135 extra_build_variables,
136 ) {
137 RequirementSatisfaction::Mismatch => {
138 debug!(
139 "Requirement installed, but mismatched:\n Installed: {installed:?}\n Requested: {source:?}"
140 );
141 }
142 RequirementSatisfaction::Satisfied => {
143 debug!("Requirement already installed: {installed}");
144 continue;
145 }
146 RequirementSatisfaction::OutOfDate => {
147 debug!("Requirement installed, but not fresh: {installed}");
148
149 // If we made it here, something went wrong in the resolver, because it returned an
150 // already-installed distribution that we "shouldn't" use. Typically, this means the
151 // distribution was considered out-of-date, but in a way that the resolver didn't
152 // detect, and is indicative of drift between the resolver's candidate selector and
153 // the install plan. For example, at present, the resolver doesn't check that an
154 // installed distribution was built with the expected build settings. Treat it as
155 // up-to-date for now; it's just means we may not rebuild a package when we otherwise
156 // should. This is a known issue, but should only affect the `uv pip` CLI, as the
157 // project APIs never return installed distributions during resolution (i.e., the
158 // resolver is stateless).
159 // TODO(charlie): Incorporate these checks into the resolver.
160 if matches!(dist, ResolvedDist::Installed { .. }) {
161 warn!(
162 "Installed distribution was considered out-of-date, but returned by the resolver: {dist}"
163 );
164 continue;
165 }
166 }
167 RequirementSatisfaction::CacheInvalid => {
168 // Already logged
169 }
170 }
171 reinstalls.push(installed.clone());
172 }
173 // We reinstall installed distributions with multiple versions because
174 // we do not want to keep multiple incompatible versions but removing
175 // one version is likely to break another.
176 _ => reinstalls.extend(installed_dists),
177 }
178 }
179
180 let ResolvedDist::Installable { dist, .. } = dist else {
181 unreachable!("Installed distribution could not be found in site-packages: {dist}");
182 };
183
184 if cache.must_revalidate_package(dist.name())
185 || dist
186 .source_tree()
187 .is_some_and(|source_tree| cache.must_revalidate_path(source_tree))
188 {
189 debug!("Must revalidate requirement: {}", dist.name());
190 remote.push(dist.clone());
191 continue;
192 }
193
194 // Identify any cached distributions that satisfy the requirement.
195 match dist.as_ref() {
196 Dist::Built(BuiltDist::Registry(wheel)) => {
197 if let Some(distribution) = registry_index.get(wheel.name()).find_map(|entry| {
198 if *entry.index.url() != wheel.best_wheel().index {
199 return None;
200 }
201 if entry.dist.filename != wheel.best_wheel().filename {
202 return None;
203 }
204 if entry.built && no_build {
205 return None;
206 }
207 if !entry.built && no_binary {
208 return None;
209 }
210 Some(&entry.dist)
211 }) {
212 debug!("Registry requirement already cached: {distribution}");
213 cached.push(CachedDist::Registry(distribution.clone()));
214 continue;
215 }
216 }
217 Dist::Built(BuiltDist::DirectUrl(wheel)) => {
218 if !wheel.filename.is_compatible(tags) {
219 let hint = generate_wheel_compatibility_hint(&wheel.filename, tags);
220 if let Some(hint) = hint {
221 bail!(
222 "A URL dependency is incompatible with the current platform: {}\n\n{}{} {}",
223 wheel.url,
224 "hint".bold().cyan(),
225 ":".bold(),
226 hint
227 );
228 }
229 bail!(
230 "A URL dependency is incompatible with the current platform: {}",
231 wheel.url
232 );
233 }
234
235 if no_binary {
236 bail!(
237 "A URL dependency points to a wheel which conflicts with `--no-binary`: {}",
238 wheel.url
239 );
240 }
241
242 // Find the exact wheel from the cache, since we know the filename in
243 // advance.
244 let cache_entry = cache
245 .shard(
246 CacheBucket::Wheels,
247 WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
248 )
249 .entry(format!("{}.http", wheel.filename.cache_key()));
250
251 // Read the HTTP pointer.
252 match HttpArchivePointer::read_from(&cache_entry) {
253 Ok(Some(pointer)) => {
254 let cache_info = pointer.to_cache_info();
255 let build_info = pointer.to_build_info();
256 let archive = pointer.into_archive();
257 if archive.satisfies(hasher.get(dist.as_ref())) {
258 let cached_dist = CachedDirectUrlDist {
259 filename: wheel.filename.clone(),
260 url: VerbatimParsedUrl {
261 parsed_url: wheel.parsed_url(),
262 verbatim: wheel.url.clone(),
263 },
264 hashes: archive.hashes,
265 cache_info,
266 build_info,
267 path: cache.archive(&archive.id).into_boxed_path(),
268 };
269
270 debug!("URL wheel requirement already cached: {cached_dist}");
271 cached.push(CachedDist::Url(cached_dist));
272 continue;
273 }
274 debug!(
275 "Cached URL wheel requirement does not match expected hash policy for: {wheel}"
276 );
277 }
278 Ok(None) => {}
279 Err(err) => {
280 debug!(
281 "Failed to deserialize cached URL wheel requirement for: {wheel} ({err})"
282 );
283 }
284 }
285 }
286 Dist::Built(BuiltDist::Path(wheel)) => {
287 // Validate that the path exists.
288 if !wheel.install_path.exists() {
289 return Err(Error::NotFound(wheel.url.to_url()).into());
290 }
291
292 if !wheel.filename.is_compatible(tags) {
293 let hint = generate_wheel_compatibility_hint(&wheel.filename, tags);
294 if let Some(hint) = hint {
295 bail!(
296 "A path dependency is incompatible with the current platform: {}\n\n{}{} {}",
297 wheel.install_path.user_display(),
298 "hint".bold().cyan(),
299 ":".bold(),
300 hint
301 );
302 }
303 bail!(
304 "A path dependency is incompatible with the current platform: {}",
305 wheel.install_path.user_display()
306 );
307 }
308
309 if no_binary {
310 bail!(
311 "A path dependency points to a wheel which conflicts with `--no-binary`: {}",
312 wheel.url
313 );
314 }
315
316 // Find the exact wheel from the cache, since we know the filename in
317 // advance.
318 let cache_entry = cache
319 .shard(
320 CacheBucket::Wheels,
321 WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
322 )
323 .entry(format!("{}.rev", wheel.filename.cache_key()));
324
325 match LocalArchivePointer::read_from(&cache_entry) {
326 Ok(Some(pointer)) => match Timestamp::from_path(&wheel.install_path) {
327 Ok(timestamp) => {
328 if pointer.is_up_to_date(timestamp) {
329 let cache_info = pointer.to_cache_info();
330 let build_info = pointer.to_build_info();
331 let archive = pointer.into_archive();
332 if archive.satisfies(hasher.get(dist.as_ref())) {
333 let cached_dist = CachedDirectUrlDist {
334 filename: wheel.filename.clone(),
335 url: VerbatimParsedUrl {
336 parsed_url: wheel.parsed_url(),
337 verbatim: wheel.url.clone(),
338 },
339 hashes: archive.hashes,
340 cache_info,
341 build_info,
342 path: cache.archive(&archive.id).into_boxed_path(),
343 };
344
345 debug!(
346 "Path wheel requirement already cached: {cached_dist}"
347 );
348 cached.push(CachedDist::Url(cached_dist));
349 continue;
350 }
351 debug!(
352 "Cached path wheel requirement does not match expected hash policy for: {wheel}"
353 );
354 }
355 }
356 Err(err) => {
357 debug!("Failed to get timestamp for wheel {wheel} ({err})");
358 }
359 },
360 Ok(None) => {}
361 Err(err) => {
362 debug!(
363 "Failed to deserialize cached path wheel requirement for: {wheel} ({err})"
364 );
365 }
366 }
367 }
368 Dist::Source(SourceDist::Registry(sdist)) => {
369 if let Some(distribution) = registry_index.get(sdist.name()).find_map(|entry| {
370 if *entry.index.url() != sdist.index {
371 return None;
372 }
373 if entry.dist.filename.name != sdist.name {
374 return None;
375 }
376 if entry.dist.filename.version != sdist.version {
377 return None;
378 }
379 if entry.built && no_build {
380 return None;
381 }
382 if !entry.built && no_binary {
383 return None;
384 }
385 Some(&entry.dist)
386 }) {
387 debug!("Registry requirement already cached: {distribution}");
388 cached.push(CachedDist::Registry(distribution.clone()));
389 continue;
390 }
391 }
392 Dist::Source(SourceDist::DirectUrl(sdist)) => {
393 // Find the most-compatible wheel from the cache, since we don't know
394 // the filename in advance.
395 match built_index.url(sdist) {
396 Ok(Some(wheel)) => {
397 if wheel.filename.name == sdist.name {
398 let cached_dist = wheel.into_url_dist(sdist);
399 debug!("URL source requirement already cached: {cached_dist}");
400 cached.push(CachedDist::Url(cached_dist));
401 continue;
402 }
403
404 warn!(
405 "Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
406 sdist, wheel.filename
407 );
408 }
409 Ok(None) => {}
410 Err(err) => {
411 debug!(
412 "Failed to deserialize cached wheel filename for: {sdist} ({err})"
413 );
414 }
415 }
416 }
417 Dist::Source(SourceDist::Git(sdist)) => {
418 // Find the most-compatible wheel from the cache, since we don't know
419 // the filename in advance.
420 if let Some(wheel) = built_index.git(sdist) {
421 if wheel.filename.name == sdist.name {
422 let cached_dist = wheel.into_git_dist(sdist);
423 debug!("Git source requirement already cached: {cached_dist}");
424 cached.push(CachedDist::Url(cached_dist));
425 continue;
426 }
427
428 warn!(
429 "Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
430 sdist, wheel.filename
431 );
432 }
433 }
434 Dist::Source(SourceDist::Path(sdist)) => {
435 // Validate that the path exists.
436 if !sdist.install_path.exists() {
437 return Err(Error::NotFound(sdist.url.to_url()).into());
438 }
439
440 // Find the most-compatible wheel from the cache, since we don't know
441 // the filename in advance.
442 match built_index.path(sdist) {
443 Ok(Some(wheel)) => {
444 if wheel.filename.name == sdist.name {
445 let cached_dist = wheel.into_path_dist(sdist);
446 debug!("Path source requirement already cached: {cached_dist}");
447 cached.push(CachedDist::Url(cached_dist));
448 continue;
449 }
450
451 warn!(
452 "Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
453 sdist, wheel.filename
454 );
455 }
456 Ok(None) => {}
457 Err(err) => {
458 debug!(
459 "Failed to deserialize cached wheel filename for: {sdist} ({err})"
460 );
461 }
462 }
463 }
464 Dist::Source(SourceDist::Directory(sdist)) => {
465 // Validate that the path exists.
466 if !sdist.install_path.exists() {
467 return Err(Error::NotFound(sdist.url.to_url()).into());
468 }
469
470 // Find the most-compatible wheel from the cache, since we don't know
471 // the filename in advance.
472 match built_index.directory(sdist) {
473 Ok(Some(wheel)) => {
474 if wheel.filename.name == sdist.name {
475 let cached_dist = wheel.into_directory_dist(sdist);
476 debug!(
477 "Directory source requirement already cached: {cached_dist}"
478 );
479 cached.push(CachedDist::Url(cached_dist));
480 continue;
481 }
482
483 warn!(
484 "Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
485 sdist, wheel.filename
486 );
487 }
488 Ok(None) => {}
489 Err(err) => {
490 debug!(
491 "Failed to deserialize cached wheel filename for: {sdist} ({err})"
492 );
493 }
494 }
495 }
496 }
497
498 debug!("Identified uncached distribution: {dist}");
499 remote.push(dist.clone());
500 }
501
502 // Remove any unnecessary packages.
503 if site_packages.any() {
504 // Retain seed packages unless: (1) the virtual environment was created by uv and
505 // (2) the `--seed` argument was not passed to `uv venv`.
506 let seed_packages = !venv.cfg().is_ok_and(|cfg| cfg.is_uv() && !cfg.is_seed());
507 for dist_info in site_packages {
508 if seed_packages && is_seed_package(&dist_info, venv) {
509 debug!("Preserving seed package: {dist_info}");
510 continue;
511 }
512
513 debug!("Unnecessary package: {dist_info}");
514 extraneous.push(dist_info);
515 }
516 }
517
518 Ok(Plan {
519 cached,
520 remote,
521 reinstalls,
522 extraneous,
523 })
524 }
525}
526
527/// Returns `true` if the given distribution is a seed package.
528fn is_seed_package(dist_info: &InstalledDist, venv: &PythonEnvironment) -> bool {
529 if venv.interpreter().python_tuple() >= (3, 12) {
530 matches!(dist_info.name().as_ref(), "uv" | "pip")
531 } else {
532 // Include `setuptools` and `wheel` on Python <3.12.
533 matches!(
534 dist_info.name().as_ref(),
535 "pip" | "setuptools" | "wheel" | "uv"
536 )
537 }
538}
539
540/// Generate a hint for explaining wheel compatibility issues.
541fn generate_wheel_compatibility_hint(filename: &WheelFilename, tags: &Tags) -> Option<String> {
542 let TagCompatibility::Incompatible(incompatible_tag) = filename.compatibility(tags) else {
543 return None;
544 };
545
546 match incompatible_tag {
547 IncompatibleTag::Python => {
548 let wheel_tags = filename.python_tags();
549 let current_tag = tags.python_tag();
550
551 if let Some(current) = current_tag {
552 let message = if let Some(pretty) = current.pretty() {
553 format!("{} (`{}`)", pretty.cyan(), current.cyan())
554 } else {
555 format!("`{}`", current.cyan())
556 };
557
558 Some(format!(
559 "The wheel is compatible with {}, but you're using {}",
560 wheel_tags
561 .iter()
562 .map(|tag| if let Some(pretty) = tag.pretty() {
563 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
564 } else {
565 format!("`{}`", tag.cyan())
566 })
567 .collect::<Vec<_>>()
568 .join(", "),
569 message
570 ))
571 } else {
572 Some(format!(
573 "The wheel requires {}",
574 wheel_tags
575 .iter()
576 .map(|tag| if let Some(pretty) = tag.pretty() {
577 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
578 } else {
579 format!("`{}`", tag.cyan())
580 })
581 .collect::<Vec<_>>()
582 .join(", ")
583 ))
584 }
585 }
586 IncompatibleTag::FreethreadedAbi => {
587 let wheel_abi = filename
588 .abi_tags()
589 .iter()
590 .map(|tag| match tag {
591 AbiTag::Abi3 => format!("the stable ABI (`{}`)", tag.cyan()),
592 _ => {
593 if let Some(pretty) = tag.pretty() {
594 format!("the {} ABI (`{}`)", pretty.cyan(), tag.cyan())
595 } else {
596 format!("`{}`", tag.cyan())
597 }
598 }
599 })
600 .collect::<Vec<_>>()
601 .join(", ");
602 let current = if let Some(current) = tags.abi_tag() {
603 if let Some(pretty) = current.pretty() {
604 format!("{} (`{}`)", pretty.cyan(), current.cyan())
605 } else {
606 format!("`{}`", current.cyan())
607 }
608 } else {
609 "free-threaded Python".to_string()
610 };
611 Some(format!(
612 "You're using {current}, but the wheel was built for {wheel_abi}, which requires a GIL-enabled interpreter"
613 ))
614 }
615 IncompatibleTag::Abi => {
616 let wheel_tags = filename.abi_tags();
617 let current_tag = tags.abi_tag();
618 if let Some(current) = current_tag {
619 let message = if let Some(pretty) = current.pretty() {
620 format!("{} (`{}`)", pretty.cyan(), current.cyan())
621 } else {
622 format!("`{}`", current.cyan())
623 };
624 Some(format!(
625 "The wheel is compatible with {}, but you're using {}",
626 wheel_tags
627 .iter()
628 .map(|tag| if let Some(pretty) = tag.pretty() {
629 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
630 } else {
631 format!("`{}`", tag.cyan())
632 })
633 .collect::<Vec<_>>()
634 .join(", "),
635 message
636 ))
637 } else {
638 Some(format!(
639 "The wheel requires {}",
640 wheel_tags
641 .iter()
642 .map(|tag| if let Some(pretty) = tag.pretty() {
643 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
644 } else {
645 format!("`{}`", tag.cyan())
646 })
647 .collect::<Vec<_>>()
648 .join(", ")
649 ))
650 }
651 }
652 IncompatibleTag::Platform => {
653 let wheel_tags = filename.platform_tags();
654 let current_tag = tags.platform_tag();
655
656 if let Some(current) = current_tag {
657 let message = if let Some(pretty) = current.pretty() {
658 format!("{} (`{}`)", pretty.cyan(), current.cyan())
659 } else {
660 format!("`{}`", current.cyan())
661 };
662 Some(format!(
663 "The wheel is compatible with {}, but you're on {}",
664 wheel_tags
665 .iter()
666 .map(|tag| if let Some(pretty) = tag.pretty() {
667 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
668 } else {
669 format!("`{}`", tag.cyan())
670 })
671 .collect::<Vec<_>>()
672 .join(", "),
673 message
674 ))
675 } else {
676 Some(format!(
677 "The wheel requires {}",
678 wheel_tags
679 .iter()
680 .map(|tag| if let Some(pretty) = tag.pretty() {
681 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
682 } else {
683 format!("`{}`", tag.cyan())
684 })
685 .collect::<Vec<_>>()
686 .join(", ")
687 ))
688 }
689 }
690 _ => None,
691 }
692}
693
694#[derive(Debug, Default)]
695pub struct Plan {
696 /// The distributions that are not already installed in the current environment, but are
697 /// available in the local cache.
698 pub cached: Vec<CachedDist>,
699
700 /// The distributions that are not already installed in the current environment, and are
701 /// not available in the local cache.
702 pub remote: Vec<Arc<Dist>>,
703
704 /// Any distributions that are already installed in the current environment, but will be
705 /// re-installed (including upgraded) to satisfy the requirements.
706 pub reinstalls: Vec<InstalledDist>,
707
708 /// Any distributions that are already installed in the current environment, and are
709 /// _not_ necessary to satisfy the requirements.
710 pub extraneous: Vec<InstalledDist>,
711}
712
713impl Plan {
714 /// Returns `true` if the plan is empty.
715 pub fn is_empty(&self) -> bool {
716 self.cached.is_empty()
717 && self.remote.is_empty()
718 && self.reinstalls.is_empty()
719 && self.extraneous.is_empty()
720 }
721
722 /// Partition the remote distributions based on a predicate function.
723 ///
724 /// Returns a tuple of plans, where the first plan contains the remote distributions that match
725 /// the predicate, and the second plan contains those that do not.
726 ///
727 /// Any extraneous and cached distributions will be returned in the first plan, while the second
728 /// plan will contain any `false` matches from the remote distributions, along with any
729 /// reinstalls for those distributions.
730 pub fn partition<F>(self, mut f: F) -> (Self, Self)
731 where
732 F: FnMut(&PackageName) -> bool,
733 {
734 let Self {
735 cached,
736 remote,
737 reinstalls,
738 extraneous,
739 } = self;
740
741 // Partition the remote distributions based on the predicate function.
742 let (left_remote, right_remote) = remote
743 .into_iter()
744 .partition::<Vec<_>, _>(|dist| f(dist.name()));
745
746 // If any remote distributions are not matched, but are already installed, ensure that
747 // they're uninstalled as part of the right plan. (Uninstalling them as part of the left
748 // plan risks uninstalling them from the environment _prior_ to the replacement being built.)
749 let (left_reinstalls, right_reinstalls) = reinstalls
750 .into_iter()
751 .partition::<Vec<_>, _>(|dist| !right_remote.iter().any(|d| d.name() == dist.name()));
752
753 // If the right plan is non-empty, then remove extraneous distributions as part of the
754 // right plan, so they're present until the very end. Otherwise, we risk removing extraneous
755 // packages that are actually build dependencies.
756 let (left_extraneous, right_extraneous) = if right_remote.is_empty() {
757 (extraneous, vec![])
758 } else {
759 (vec![], extraneous)
760 };
761
762 // Always include the cached distributions in the left plan.
763 let (left_cached, right_cached) = (cached, vec![]);
764
765 // Include all cached and extraneous distributions in the left plan.
766 let left_plan = Self {
767 cached: left_cached,
768 remote: left_remote,
769 reinstalls: left_reinstalls,
770 extraneous: left_extraneous,
771 };
772
773 // The right plan will only contain the remote distributions that did not match the predicate,
774 // along with any reinstalls for those distributions.
775 let right_plan = Self {
776 cached: right_cached,
777 remote: right_remote,
778 reinstalls: right_reinstalls,
779 extraneous: right_extraneous,
780 };
781
782 (left_plan, right_plan)
783 }
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789 use std::str::FromStr;
790 use uv_platform_tags::{Arch, Os, Platform};
791
792 #[test]
793 fn test_abi3_on_free_threaded_python_hint() {
794 // Create a Tags object for free-threaded Python 3.14
795 let platform = Platform::new(
796 Os::Manylinux {
797 major: 2,
798 minor: 28,
799 },
800 Arch::X86_64,
801 );
802 let tags = Tags::from_env(
803 &platform,
804 (3, 14), // python_version
805 "cpython", // implementation_name
806 (3, 14), // implementation_version
807 true, // manylinux_compatible
808 true, // gil_disabled (free-threaded)
809 false, // is_cross
810 )
811 .unwrap();
812
813 // Create a wheel filename with abi3 tag
814 let filename =
815 WheelFilename::from_str("foo-1.0-cp37-abi3-manylinux_2_17_x86_64.whl").unwrap();
816
817 // Generate the hint
818 let hint = generate_wheel_compatibility_hint(&filename, &tags).unwrap();
819
820 let hint = anstream::adapter::strip_str(&hint);
821 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");
822 }
823
824 #[test]
825 fn test_gil_enabled_cpython_on_free_threaded_python_hint() {
826 // Create a Tags object for free-threaded Python 3.14
827 let platform = Platform::new(
828 Os::Manylinux {
829 major: 2,
830 minor: 28,
831 },
832 Arch::X86_64,
833 );
834 let tags = Tags::from_env(
835 &platform,
836 (3, 14), // python_version
837 "cpython", // implementation_name
838 (3, 14), // implementation_version
839 true, // manylinux_compatible
840 true, // gil_disabled (free-threaded)
841 false, // is_cross
842 )
843 .unwrap();
844
845 // Create a wheel filename with cp314 ABI tag (same version, GIL-enabled)
846 let filename =
847 WheelFilename::from_str("foo-1.0-cp314-cp314-manylinux_2_17_x86_64.whl").unwrap();
848
849 // Generate the hint
850 let hint = generate_wheel_compatibility_hint(&filename, &tags).unwrap();
851
852 let hint = anstream::adapter::strip_str(&hint);
853 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");
854 }
855
856 #[test]
857 fn test_abi3_on_regular_python_no_special_hint() {
858 // Create a Tags object for regular (non-free-threaded) Python 3.14
859 let platform = Platform::new(
860 Os::Manylinux {
861 major: 2,
862 minor: 28,
863 },
864 Arch::X86_64,
865 );
866 let tags = Tags::from_env(
867 &platform,
868 (3, 14), // python_version
869 "cpython", // implementation_name
870 (3, 14), // implementation_version
871 true, // manylinux_compatible
872 false, // gil_disabled (regular Python)
873 false, // is_cross
874 )
875 .unwrap();
876
877 // Create a wheel filename with abi3 tag
878 let filename =
879 WheelFilename::from_str("foo-1.0-cp37-abi3-manylinux_2_17_x86_64.whl").unwrap();
880
881 // The wheel should be compatible (abi3 works on regular Python)
882 let hint = generate_wheel_compatibility_hint(&filename, &tags);
883
884 // No hint should be generated because the wheel is compatible
885 assert!(
886 hint.is_none(),
887 "Expected no hint (wheel should be compatible), got: {hint:?}"
888 );
889 }
890}