Skip to main content

uv_installer/
plan.rs

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