Skip to main content

uv_distribution/
error.rs

1use std::fmt;
2use std::path::PathBuf;
3
4use owo_colors::OwoColorize;
5use tokio::task::JoinError;
6
7use crate::metadata::MetadataError;
8use uv_cache::Error as CacheError;
9use uv_client::WrappedReqwestError;
10use uv_distribution_filename::{WheelFilename, WheelFilenameError};
11use uv_distribution_types::{InstalledDist, InstalledDistError, IsBuildBackendError};
12use uv_fs::Simplified;
13use uv_git::GitError;
14use uv_normalize::PackageName;
15use uv_pep440::{Version, VersionSpecifiers};
16use uv_platform_tags::Platform;
17use uv_pypi_types::{HashAlgorithm, HashDigest};
18use uv_python::PythonVariant;
19use uv_redacted::DisplaySafeUrl;
20use uv_types::AnyErrorBuild;
21
22#[derive(Debug, Clone, Copy)]
23pub struct PythonVersion {
24    pub(crate) version: (u8, u8),
25    pub(crate) variant: PythonVariant,
26}
27
28impl fmt::Display for PythonVersion {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        let (major, minor) = self.version;
31        write!(f, "{major}.{minor}{}", self.variant.executable_suffix())
32    }
33}
34
35#[derive(Debug, thiserror::Error)]
36pub enum Error {
37    #[error("Building source distributions is disabled")]
38    NoBuild,
39    #[error("Building source distributions for `{0}` is disabled")]
40    NoBuildPackage(PackageName),
41
42    // Network error
43    #[error(transparent)]
44    InvalidUrl(#[from] uv_distribution_types::ToUrlError),
45    #[error("Expected a file URL, but received: {0}")]
46    NonFileUrl(DisplaySafeUrl),
47    #[error(transparent)]
48    Git(#[from] uv_git::GitResolverError),
49    #[error(transparent)]
50    Reqwest(#[from] WrappedReqwestError),
51    #[error(transparent)]
52    Client(#[from] uv_client::Error),
53    #[error(transparent)]
54    ClientBuild(#[from] uv_client::ClientBuildError),
55
56    // Cache writing error
57    #[error("Failed to read from the distribution cache")]
58    CacheRead(#[source] std::io::Error),
59    #[error("Failed to write to the distribution cache")]
60    CacheWrite(#[source] std::io::Error),
61    #[error("Failed to acquire lock on the distribution cache")]
62    CacheLock(#[source] CacheError),
63    #[error("Failed to deserialize cache entry")]
64    CacheDecode(#[from] rmp_serde::decode::Error),
65    #[error("Failed to serialize cache entry")]
66    CacheEncode(#[from] rmp_serde::encode::Error),
67    #[error("Failed to walk the distribution cache")]
68    CacheWalk(#[source] walkdir::Error),
69    #[error(transparent)]
70    CacheInfo(#[from] uv_cache_info::CacheInfoError),
71
72    // Build error
73    #[error(transparent)]
74    Build(AnyErrorBuild),
75    #[error("Built wheel has an invalid filename")]
76    WheelFilename(#[from] WheelFilenameError),
77    #[error("Package metadata name `{metadata}` does not match given name `{given}`")]
78    WheelMetadataNameMismatch {
79        given: PackageName,
80        metadata: PackageName,
81    },
82    #[error("Package metadata version `{metadata}` does not match given version `{given}`")]
83    WheelMetadataVersionMismatch { given: Version, metadata: Version },
84    #[error(
85        "Package metadata name `{metadata}` does not match `{filename}` from the wheel filename"
86    )]
87    WheelFilenameNameMismatch {
88        filename: PackageName,
89        metadata: PackageName,
90    },
91    #[error(
92        "Package metadata version `{metadata}` does not match `{filename}` from the wheel filename"
93    )]
94    WheelFilenameVersionMismatch {
95        filename: Version,
96        metadata: Version,
97    },
98    /// This shouldn't happen, it's a bug in the build backend.
99    #[error(
100        "The built wheel `{}` is not compatible with the current Python {} on {}",
101        filename,
102        python_version,
103        python_platform.pretty(),
104    )]
105    BuiltWheelIncompatibleHostPlatform {
106        filename: WheelFilename,
107        python_platform: Platform,
108        python_version: PythonVersion,
109    },
110    /// This may happen when trying to cross-install native dependencies without their build backend
111    /// being aware that the target is a cross-install.
112    #[error(
113        "The built wheel `{}` is not compatible with the target Python {} on {}. Consider using `--no-build` to disable building wheels.",
114        filename,
115        python_version,
116        python_platform.pretty(),
117    )]
118    BuiltWheelIncompatibleTargetPlatform {
119        filename: WheelFilename,
120        python_platform: Platform,
121        python_version: PythonVersion,
122    },
123    #[error("Failed to parse metadata from built wheel")]
124    Metadata(#[from] uv_pypi_types::MetadataError),
125    #[error("Failed to read metadata: `{}`", _0.user_display())]
126    WheelMetadata(PathBuf, #[source] Box<uv_metadata::Error>),
127    #[error("Failed to read metadata from installed package `{0}`")]
128    ReadInstalled(Box<InstalledDist>, #[source] InstalledDistError),
129    #[error("Failed to extract archive: {0}")]
130    Extract(String, #[source] uv_extract::Error),
131    #[error("The source distribution is missing a `PKG-INFO` file")]
132    MissingPkgInfo,
133    #[error("The source distribution `{}` has no subdirectory `{}`", _0, _1.display())]
134    MissingSubdirectory(DisplaySafeUrl, PathBuf),
135    #[error("The source distribution `{0}` is missing Git LFS artifacts.")]
136    MissingSourceDistGitLfsArtifacts(DisplaySafeUrl, #[source] GitError),
137    #[error("The wheel `{0}` is missing Git LFS artifacts.")]
138    MissingWheelGitLfsArtifacts(DisplaySafeUrl, #[source] GitError),
139    #[error("Failed to extract static metadata from `PKG-INFO`")]
140    PkgInfo(#[source] uv_pypi_types::MetadataError),
141    #[error("The source distribution is missing a `pyproject.toml` file")]
142    MissingPyprojectToml,
143    #[error("Failed to extract static metadata from `pyproject.toml`")]
144    PyprojectToml(#[source] uv_pypi_types::MetadataError),
145    #[error(transparent)]
146    MetadataLowering(#[from] MetadataError),
147    #[error("Distribution not found at: {0}")]
148    NotFound(DisplaySafeUrl),
149    #[error("Attempted to re-extract the source distribution for `{}`, but the {} hash didn't match. Run `{}` to clear the cache.", _0, _1, "uv cache clean".green())]
150    CacheHeal(String, HashAlgorithm),
151    #[error("The source distribution requires Python {0}, but {1} is installed")]
152    RequiresPython(VersionSpecifiers, Version),
153    #[error("Failed to identify base Python interpreter")]
154    BaseInterpreter(#[source] std::io::Error),
155
156    /// A generic request middleware error happened while making a request.
157    /// Refer to the error message for more details.
158    #[error(transparent)]
159    ReqwestMiddlewareError(#[from] anyhow::Error),
160
161    /// Should not occur; only seen when another task panicked.
162    #[error("The task executor is broken, did some other task panic?")]
163    Join(#[from] JoinError),
164
165    /// An I/O error that occurs while exhausting a reader to compute a hash.
166    #[error("Failed to hash distribution")]
167    HashExhaustion(#[source] std::io::Error),
168
169    #[error("Hash mismatch for `{distribution}`\n\nExpected:\n{expected}\n\nComputed:\n{actual}")]
170    MismatchedHashes {
171        distribution: String,
172        expected: String,
173        actual: String,
174    },
175
176    #[error(
177        "Hash-checking is enabled, but no hashes were provided or computed for: `{distribution}`"
178    )]
179    MissingHashes { distribution: String },
180
181    #[error(
182        "Hash-checking is enabled, but no hashes were computed for: `{distribution}`\n\nExpected:\n{expected}"
183    )]
184    MissingActualHashes {
185        distribution: String,
186        expected: String,
187    },
188
189    #[error(
190        "Hash-checking is enabled, but no hashes were provided for: `{distribution}`\n\nComputed:\n{actual}"
191    )]
192    MissingExpectedHashes {
193        distribution: String,
194        actual: String,
195    },
196
197    #[error("Hash-checking is not supported for local directories: `{0}`")]
198    HashesNotSupportedSourceTree(String),
199
200    #[error("Hash-checking is not supported for Git repositories: `{0}`")]
201    HashesNotSupportedGit(String),
202
203    #[error(transparent)]
204    InstallWheelError(uv_install_wheel::Error),
205}
206
207impl From<reqwest::Error> for Error {
208    fn from(error: reqwest::Error) -> Self {
209        Self::Reqwest(WrappedReqwestError::from(error))
210    }
211}
212
213impl From<reqwest_middleware::Error> for Error {
214    fn from(error: reqwest_middleware::Error) -> Self {
215        match error {
216            reqwest_middleware::Error::Middleware(error) => Self::ReqwestMiddlewareError(error),
217            reqwest_middleware::Error::Reqwest(error) => {
218                Self::Reqwest(WrappedReqwestError::from(error))
219            }
220        }
221    }
222}
223
224impl uv_errors::Hint for Error {
225    fn hints(&self) -> uv_errors::Hints<'_> {
226        match self {
227            Self::Build(err) => err.hints(),
228            Self::Client(err) => uv_errors::Hint::hints(err),
229            Self::MetadataLowering(err) => err.hints(),
230            _ => uv_errors::Hints::none(),
231        }
232    }
233}
234
235impl IsBuildBackendError for Error {
236    fn is_build_backend_error(&self) -> bool {
237        match self {
238            Self::Build(err) => err.is_build_backend_error(),
239            _ => false,
240        }
241    }
242}
243
244impl Error {
245    /// Construct a hash mismatch error.
246    pub fn hash_mismatch(
247        distribution: String,
248        expected: &[HashDigest],
249        actual: &[HashDigest],
250    ) -> Self {
251        match (expected.is_empty(), actual.is_empty()) {
252            (true, true) => Self::MissingHashes { distribution },
253            (true, false) => {
254                let actual = actual
255                    .iter()
256                    .map(|hash| format!("  {hash}"))
257                    .collect::<Vec<_>>()
258                    .join("\n");
259
260                Self::MissingExpectedHashes {
261                    distribution,
262                    actual,
263                }
264            }
265            (false, true) => {
266                let expected = expected
267                    .iter()
268                    .map(|hash| format!("  {hash}"))
269                    .collect::<Vec<_>>()
270                    .join("\n");
271
272                Self::MissingActualHashes {
273                    distribution,
274                    expected,
275                }
276            }
277            (false, false) => {
278                let expected = expected
279                    .iter()
280                    .map(|hash| format!("  {hash}"))
281                    .collect::<Vec<_>>()
282                    .join("\n");
283
284                let actual = actual
285                    .iter()
286                    .map(|hash| format!("  {hash}"))
287                    .collect::<Vec<_>>()
288                    .join("\n");
289
290                Self::MismatchedHashes {
291                    distribution,
292                    expected,
293                    actual,
294                }
295            }
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::{Error, PythonVersion};
303    use std::str::FromStr;
304    use uv_distribution_filename::WheelFilename;
305    use uv_platform_tags::{Arch, Os, Platform};
306    use uv_python::PythonVariant;
307
308    #[test]
309    fn built_wheel_error_formats_freethreaded_python() {
310        let err = Error::BuiltWheelIncompatibleHostPlatform {
311            filename: WheelFilename::from_str(
312                "cryptography-47.0.0.dev1-cp315-abi3t-macosx_11_0_arm64.whl",
313            )
314            .unwrap(),
315            python_platform: Platform::new(
316                Os::Macos {
317                    major: 11,
318                    minor: 0,
319                },
320                Arch::Aarch64,
321            ),
322            python_version: PythonVersion {
323                version: (3, 15),
324                variant: PythonVariant::Freethreaded,
325            },
326        };
327
328        assert_eq!(
329            err.to_string(),
330            "The built wheel `cryptography-47.0.0.dev1-cp315-abi3t-macosx_11_0_arm64.whl` is not compatible with the current Python 3.15t on macOS aarch64"
331        );
332    }
333
334    #[test]
335    fn built_wheel_error_formats_target_python() {
336        let err = Error::BuiltWheelIncompatibleTargetPlatform {
337            filename: WheelFilename::from_str("py313-0.1.0-py313-none-any.whl").unwrap(),
338            python_platform: Platform::new(
339                Os::Manylinux {
340                    major: 2,
341                    minor: 28,
342                },
343                Arch::X86_64,
344            ),
345            python_version: PythonVersion {
346                version: (3, 12),
347                variant: PythonVariant::Default,
348            },
349        };
350
351        assert_eq!(
352            err.to_string(),
353            "The built wheel `py313-0.1.0-py313-none-any.whl` is not compatible with the target Python 3.12 on Linux x86_64. Consider using `--no-build` to disable building wheels."
354        );
355    }
356}