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::CPython {
592 gil_disabled: false,
593 python_version: (major, minor),
594 } => {
595 format!("the CPython {}.{} ABI (`{}`)", major, minor, tag.cyan())
596 }
597 AbiTag::Abi3 => format!("the stable ABI (`{}`)", tag.cyan()),
598 _ => {
599 if let Some(pretty) = tag.pretty() {
600 format!("the {} ABI (`{}`)", pretty.cyan(), tag.cyan())
601 } else {
602 format!("`{}`", tag.cyan())
603 }
604 }
605 })
606 .collect::<Vec<_>>()
607 .join(", ");
608 let current = if let Some(current) = tags.abi_tag() {
609 if let Some(pretty) = current.pretty() {
610 format!("{} (`{}`)", pretty.cyan(), current.cyan())
611 } else {
612 format!("`{}`", current.cyan())
613 }
614 } else {
615 "free-threaded Python".to_string()
616 };
617 Some(format!(
618 "You're using {current}, but the wheel was built for {wheel_abi}, which requires a GIL-enabled interpreter"
619 ))
620 }
621 IncompatibleTag::Abi => {
622 let wheel_tags = filename.abi_tags();
623 let current_tag = tags.abi_tag();
624 if let Some(current) = current_tag {
625 let message = if let Some(pretty) = current.pretty() {
626 format!("{} (`{}`)", pretty.cyan(), current.cyan())
627 } else {
628 format!("`{}`", current.cyan())
629 };
630 Some(format!(
631 "The wheel is compatible with {}, but you're using {}",
632 wheel_tags
633 .iter()
634 .map(|tag| if let Some(pretty) = tag.pretty() {
635 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
636 } else {
637 format!("`{}`", tag.cyan())
638 })
639 .collect::<Vec<_>>()
640 .join(", "),
641 message
642 ))
643 } else {
644 Some(format!(
645 "The wheel requires {}",
646 wheel_tags
647 .iter()
648 .map(|tag| if let Some(pretty) = tag.pretty() {
649 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
650 } else {
651 format!("`{}`", tag.cyan())
652 })
653 .collect::<Vec<_>>()
654 .join(", ")
655 ))
656 }
657 }
658 IncompatibleTag::Platform => {
659 let wheel_tags = filename.platform_tags();
660 let current_tag = tags.platform_tag();
661
662 if let Some(current) = current_tag {
663 let message = if let Some(pretty) = current.pretty() {
664 format!("{} (`{}`)", pretty.cyan(), current.cyan())
665 } else {
666 format!("`{}`", current.cyan())
667 };
668 Some(format!(
669 "The wheel is compatible with {}, but you're on {}",
670 wheel_tags
671 .iter()
672 .map(|tag| if let Some(pretty) = tag.pretty() {
673 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
674 } else {
675 format!("`{}`", tag.cyan())
676 })
677 .collect::<Vec<_>>()
678 .join(", "),
679 message
680 ))
681 } else {
682 Some(format!(
683 "The wheel requires {}",
684 wheel_tags
685 .iter()
686 .map(|tag| if let Some(pretty) = tag.pretty() {
687 format!("{} (`{}`)", pretty.cyan(), tag.cyan())
688 } else {
689 format!("`{}`", tag.cyan())
690 })
691 .collect::<Vec<_>>()
692 .join(", ")
693 ))
694 }
695 }
696 _ => None,
697 }
698}
699
700#[derive(Debug, Default)]
701pub struct Plan {
702 /// The distributions that are not already installed in the current environment, but are
703 /// available in the local cache.
704 pub cached: Vec<CachedDist>,
705
706 /// The distributions that are not already installed in the current environment, and are
707 /// not available in the local cache.
708 pub remote: Vec<Arc<Dist>>,
709
710 /// Any distributions that are already installed in the current environment, but will be
711 /// re-installed (including upgraded) to satisfy the requirements.
712 pub reinstalls: Vec<InstalledDist>,
713
714 /// Any distributions that are already installed in the current environment, and are
715 /// _not_ necessary to satisfy the requirements.
716 pub extraneous: Vec<InstalledDist>,
717}
718
719impl Plan {
720 /// Returns `true` if the plan is empty.
721 pub fn is_empty(&self) -> bool {
722 self.cached.is_empty()
723 && self.remote.is_empty()
724 && self.reinstalls.is_empty()
725 && self.extraneous.is_empty()
726 }
727
728 /// Partition the remote distributions based on a predicate function.
729 ///
730 /// Returns a tuple of plans, where the first plan contains the remote distributions that match
731 /// the predicate, and the second plan contains those that do not.
732 ///
733 /// Any extraneous and cached distributions will be returned in the first plan, while the second
734 /// plan will contain any `false` matches from the remote distributions, along with any
735 /// reinstalls for those distributions.
736 pub fn partition<F>(self, mut f: F) -> (Self, Self)
737 where
738 F: FnMut(&PackageName) -> bool,
739 {
740 let Self {
741 cached,
742 remote,
743 reinstalls,
744 extraneous,
745 } = self;
746
747 // Partition the remote distributions based on the predicate function.
748 let (left_remote, right_remote) = remote
749 .into_iter()
750 .partition::<Vec<_>, _>(|dist| f(dist.name()));
751
752 // If any remote distributions are not matched, but are already installed, ensure that
753 // they're uninstalled as part of the right plan. (Uninstalling them as part of the left
754 // plan risks uninstalling them from the environment _prior_ to the replacement being built.)
755 let (left_reinstalls, right_reinstalls) = reinstalls
756 .into_iter()
757 .partition::<Vec<_>, _>(|dist| !right_remote.iter().any(|d| d.name() == dist.name()));
758
759 // If the right plan is non-empty, then remove extraneous distributions as part of the
760 // right plan, so they're present until the very end. Otherwise, we risk removing extraneous
761 // packages that are actually build dependencies.
762 let (left_extraneous, right_extraneous) = if right_remote.is_empty() {
763 (extraneous, vec![])
764 } else {
765 (vec![], extraneous)
766 };
767
768 // Always include the cached distributions in the left plan.
769 let (left_cached, right_cached) = (cached, vec![]);
770
771 // Include all cached and extraneous distributions in the left plan.
772 let left_plan = Self {
773 cached: left_cached,
774 remote: left_remote,
775 reinstalls: left_reinstalls,
776 extraneous: left_extraneous,
777 };
778
779 // The right plan will only contain the remote distributions that did not match the predicate,
780 // along with any reinstalls for those distributions.
781 let right_plan = Self {
782 cached: right_cached,
783 remote: right_remote,
784 reinstalls: right_reinstalls,
785 extraneous: right_extraneous,
786 };
787
788 (left_plan, right_plan)
789 }
790}
791
792#[cfg(test)]
793mod tests {
794 use super::*;
795 use std::str::FromStr;
796 use uv_platform_tags::{Arch, Os, Platform};
797
798 #[test]
799 fn test_abi3_on_free_threaded_python_hint() {
800 // Create a Tags object for free-threaded Python 3.14
801 let platform = Platform::new(
802 Os::Manylinux {
803 major: 2,
804 minor: 28,
805 },
806 Arch::X86_64,
807 );
808 let tags = Tags::from_env(
809 &platform,
810 (3, 14), // python_version
811 "cpython", // implementation_name
812 (3, 14), // implementation_version
813 true, // manylinux_compatible
814 true, // gil_disabled (free-threaded)
815 false, // is_cross
816 )
817 .unwrap();
818
819 // Create a wheel filename with abi3 tag
820 let filename =
821 WheelFilename::from_str("foo-1.0-cp37-abi3-manylinux_2_17_x86_64.whl").unwrap();
822
823 // Generate the hint
824 let hint = generate_wheel_compatibility_hint(&filename, &tags).unwrap();
825
826 let hint = anstream::adapter::strip_str(&hint);
827 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");
828 }
829
830 #[test]
831 fn test_gil_enabled_cpython_on_free_threaded_python_hint() {
832 // Create a Tags object for free-threaded Python 3.14
833 let platform = Platform::new(
834 Os::Manylinux {
835 major: 2,
836 minor: 28,
837 },
838 Arch::X86_64,
839 );
840 let tags = Tags::from_env(
841 &platform,
842 (3, 14), // python_version
843 "cpython", // implementation_name
844 (3, 14), // implementation_version
845 true, // manylinux_compatible
846 true, // gil_disabled (free-threaded)
847 false, // is_cross
848 )
849 .unwrap();
850
851 // Create a wheel filename with cp314 ABI tag (same version, GIL-enabled)
852 let filename =
853 WheelFilename::from_str("foo-1.0-cp314-cp314-manylinux_2_17_x86_64.whl").unwrap();
854
855 // Generate the hint
856 let hint = generate_wheel_compatibility_hint(&filename, &tags).unwrap();
857
858 let hint = anstream::adapter::strip_str(&hint);
859 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");
860 }
861
862 #[test]
863 fn test_abi3_on_regular_python_no_special_hint() {
864 // Create a Tags object for regular (non-free-threaded) Python 3.14
865 let platform = Platform::new(
866 Os::Manylinux {
867 major: 2,
868 minor: 28,
869 },
870 Arch::X86_64,
871 );
872 let tags = Tags::from_env(
873 &platform,
874 (3, 14), // python_version
875 "cpython", // implementation_name
876 (3, 14), // implementation_version
877 true, // manylinux_compatible
878 false, // gil_disabled (regular Python)
879 false, // is_cross
880 )
881 .unwrap();
882
883 // Create a wheel filename with abi3 tag
884 let filename =
885 WheelFilename::from_str("foo-1.0-cp37-abi3-manylinux_2_17_x86_64.whl").unwrap();
886
887 // The wheel should be compatible (abi3 works on regular Python)
888 let hint = generate_wheel_compatibility_hint(&filename, &tags);
889
890 // No hint should be generated because the wheel is compatible
891 assert!(
892 hint.is_none(),
893 "Expected no hint (wheel should be compatible), got: {hint:?}"
894 );
895 }
896}