Skip to main content

uv_configuration/
excludes.rs

1use std::fmt;
2use std::str::FromStr;
3
4use rustc_hash::{FxHashMap, FxHashSet};
5use serde::de::{Error, MapAccess, Visitor, value::MapAccessDeserializer};
6use serde::{Deserialize, Deserializer};
7
8use uv_distribution_types::ResolutionRecorder;
9use uv_normalize::PackageName;
10use uv_pep440::Version;
11
12use crate::Overrides;
13
14/// A set of exclusions that applies to the dependencies of a specific package version.
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
16#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
17#[serde(rename_all = "kebab-case", deny_unknown_fields)]
18pub struct PackageExclusion {
19    package: PackageExclusionTarget,
20    dependencies: Box<[PackageName]>,
21}
22
23/// The package and optional version selected by a [`PackageExclusion`].
24#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
25#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
26#[serde(rename_all = "kebab-case", deny_unknown_fields)]
27pub struct PackageExclusionTarget {
28    name: PackageName,
29    #[cfg_attr(
30        feature = "schemars",
31        schemars(
32            with = "Option<String>",
33            description = "PEP 440-style package version, e.g., `1.2.3`"
34        )
35    )]
36    version: Option<Version>,
37}
38
39impl PackageExclusion {
40    /// Return the parent package selected by this exclusion.
41    pub fn package(&self) -> &PackageName {
42        &self.package.name
43    }
44}
45
46/// An exclusion, either global or scoped to a specific package version.
47#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
48#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema), schemars(untagged))]
49#[serde(untagged)]
50pub enum ExcludeDependency {
51    Package(PackageExclusion),
52    Dependency(PackageName),
53}
54
55struct ExcludeDependencyVisitor;
56
57impl<'de> Visitor<'de> for ExcludeDependencyVisitor {
58    type Value = ExcludeDependency;
59
60    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        formatter.write_str("a string or map")
62    }
63
64    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
65    where
66        E: Error,
67    {
68        PackageName::from_str(value)
69            .map(ExcludeDependency::Dependency)
70            .map_err(E::custom)
71    }
72
73    fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
74    where
75        M: MapAccess<'de>,
76    {
77        PackageExclusion::deserialize(MapAccessDeserializer::new(map))
78            .map(ExcludeDependency::Package)
79    }
80}
81
82impl<'de> Deserialize<'de> for ExcludeDependency {
83    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
84    where
85        D: Deserializer<'de>,
86    {
87        deserializer.deserialize_any(ExcludeDependencyVisitor)
88    }
89}
90
91/// A set of packages to exclude from resolution.
92#[derive(Debug, Default, Clone)]
93pub struct Excludes {
94    recorder: Option<ResolutionRecorder>,
95    global: FxHashSet<PackageName>,
96    scoped: FxHashMap<PackageName, Vec<ScopedExclusions>>,
97}
98
99#[derive(Debug, Clone)]
100struct ScopedExclusions {
101    version: Option<Version>,
102    excludes: FxHashSet<PackageName>,
103}
104
105impl Excludes {
106    /// Record which settings are consulted while resolving runtime dependencies.
107    #[must_use]
108    pub(crate) fn with_recorder(mut self, recorder: Option<ResolutionRecorder>) -> Self {
109        self.recorder = recorder;
110        self
111    }
112
113    /// Create an indexed set of exclusions.
114    pub fn from_entries(entries: impl IntoIterator<Item = ExcludeDependency>) -> Self {
115        let mut excludes = Self::default();
116        for entry in entries {
117            match entry {
118                ExcludeDependency::Dependency(dependency) => {
119                    excludes.global.insert(dependency);
120                }
121                ExcludeDependency::Package(package) => {
122                    let packages = excludes.scoped.entry(package.package.name).or_default();
123                    if let Some(entry) = packages
124                        .iter_mut()
125                        .find(|entry| entry.version == package.package.version)
126                    {
127                        entry.excludes.extend(package.dependencies);
128                    } else {
129                        packages.push(ScopedExclusions {
130                            version: package.package.version,
131                            excludes: package.dependencies.into_iter().collect(),
132                        });
133                    }
134                }
135            }
136        }
137        excludes
138    }
139
140    /// Return sorted declarations, combining duplicate exclusions within each package scope.
141    ///
142    /// Retain empty version-specific scopes: they shadow exclusions from a versionless scope.
143    pub(crate) fn into_entries(self) -> Vec<ExcludeDependency> {
144        let mut entries = self
145            .global
146            .into_iter()
147            .map(ExcludeDependency::Dependency)
148            .collect::<Vec<_>>();
149        for (name, packages) in self.scoped {
150            for package in packages {
151                let mut dependencies = package.excludes.into_iter().collect::<Vec<_>>();
152                dependencies.sort();
153                entries.push(ExcludeDependency::Package(PackageExclusion {
154                    package: PackageExclusionTarget {
155                        name: name.clone(),
156                        version: package.version,
157                    },
158                    dependencies: dependencies.into_boxed_slice(),
159                }));
160            }
161        }
162        entries.sort();
163        entries
164    }
165
166    /// Check if a package is excluded.
167    pub(crate) fn contains(&self, name: &PackageName) -> bool {
168        if let Some(recorder) = &self.recorder {
169            recorder.exclusion(name);
170        }
171        self.global.contains(name)
172    }
173
174    /// Return whether any exclusions are scoped to the given package.
175    pub(crate) fn has_scoped_package(&self, package: &PackageName) -> bool {
176        self.scoped.contains_key(package)
177    }
178
179    /// Check if a dependency is excluded from a specific package version.
180    pub(crate) fn contains_for(
181        &self,
182        package: &PackageName,
183        version: &Version,
184        dependency: &PackageName,
185    ) -> bool {
186        self.contains_for_package(Some((package, version)), dependency)
187    }
188
189    /// Check if a dependency is always excluded from a package scope.
190    ///
191    /// A versionless scope remains eligible if any exact-version exclusion allows the dependency
192    /// at a version where the override is not shadowed by an exact override scope.
193    pub(crate) fn contains_for_scope(
194        &self,
195        overrides: &Overrides,
196        package: &PackageName,
197        version: Option<&Version>,
198        dependency: &PackageName,
199    ) -> bool {
200        if let Some(version) = version {
201            return self.contains_for(package, version, dependency);
202        }
203        if self.contains(dependency) {
204            return true;
205        }
206
207        let Some(entries) = self.scoped.get(package) else {
208            return false;
209        };
210        entries
211            .iter()
212            .find(|entry| entry.version.is_none())
213            .is_some_and(|entry| entry.excludes.contains(dependency))
214            && entries
215                .iter()
216                .filter(|entry| {
217                    entry
218                        .version
219                        .as_ref()
220                        .is_some_and(|version| !overrides.has_exact_scope(package, version))
221                })
222                .all(|entry| entry.excludes.contains(dependency))
223    }
224
225    /// Check if a dependency is excluded with optional package-version context.
226    pub(crate) fn contains_for_package(
227        &self,
228        package: Option<(&PackageName, &Version)>,
229        dependency: &PackageName,
230    ) -> bool {
231        self.contains(dependency)
232            || package.is_some_and(|(package, version)| {
233                if let Some(recorder) = &self.recorder {
234                    recorder.scoped_exclusion(package);
235                }
236                self.scoped.get(package).is_some_and(|entries| {
237                    entries
238                        .iter()
239                        .find(|entry| entry.version.as_ref() == Some(version))
240                        .or_else(|| entries.iter().find(|entry| entry.version.is_none()))
241                        .is_some_and(|entry| entry.excludes.contains(dependency))
242                })
243            })
244    }
245}
246
247impl FromIterator<PackageName> for Excludes {
248    fn from_iter<I: IntoIterator<Item = PackageName>>(iter: I) -> Self {
249        Self::from_entries(iter.into_iter().map(ExcludeDependency::Dependency))
250    }
251}