Skip to main content

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}