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_types::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        "Size mismatch for `{distribution}`: expected {expected} bytes, but downloaded {actual} bytes"
178    )]
179    MismatchedSize {
180        distribution: String,
181        expected: u64,
182        actual: u64,
183    },
184
185    #[error(
186        "Content-Length mismatch for `{distribution}`: expected {expected} bytes, but the server advertised {actual} bytes"
187    )]
188    MismatchedContentLength {
189        distribution: String,
190        expected: u64,
191        actual: u64,
192    },
193
194    #[error(
195        "Range response size mismatch for `{distribution}`: expected {expected} bytes from Content-Range, but received {actual} bytes"
196    )]
197    MismatchedRangeSize {
198        distribution: String,
199        expected: u64,
200        actual: u64,
201    },
202
203    #[error(
204        "Hash-checking is enabled, but no hashes were provided or computed for: {distribution}"
205    )]
206    MissingHashes { distribution: String },
207
208    #[error(
209        "Hash-checking is enabled, but no hashes were computed for `{distribution}`\n\nExpected:\n{expected}"
210    )]
211    MissingActualHashes {
212        distribution: String,
213        expected: String,
214    },
215
216    #[error(
217        "Hash-checking is enabled, but no hashes were provided for `{distribution}`\n\nComputed:\n{actual}"
218    )]
219    MissingExpectedHashes {
220        distribution: String,
221        actual: String,
222    },
223
224    #[error("Hash-checking is not supported for local directories: {0}")]
225    HashesNotSupportedSourceTree(String),
226
227    #[error("Hash-checking is not supported for Git repositories: {0}")]
228    HashesNotSupportedGit(String),
229
230    #[error(transparent)]
231    InstallWheelError(uv_install_wheel::Error),
232}
233
234fn is_not_found_error(mut error: &(dyn std::error::Error + 'static)) -> bool {
235    loop {
236        if error
237            .downcast_ref::<reqwest::Error>()
238            .and_then(reqwest::Error::status)
239            .is_some_and(|status| status == reqwest::StatusCode::NOT_FOUND)
240        {
241            return true;
242        }
243        let Some(source) = error.source() else {
244            return false;
245        };
246        error = source;
247    }
248}
249
250impl From<reqwest::Error> for Error {
251    fn from(error: reqwest::Error) -> Self {
252        Self::Reqwest(WrappedReqwestError::from(error))
253    }
254}
255
256impl From<reqwest_middleware::Error> for Error {
257    fn from(error: reqwest_middleware::Error) -> Self {
258        match error {
259            reqwest_middleware::Error::Middleware(error) => Self::ReqwestMiddlewareError(error),
260            reqwest_middleware::Error::Reqwest(error) => {
261                Self::Reqwest(WrappedReqwestError::from(error))
262            }
263        }
264    }
265}
266
267impl uv_errors::Hinted for Error {
268    fn hints(&self) -> uv_errors::Hints<'_> {
269        match self {
270            Self::Build(err) => err.hints(),
271            Self::Client(err) => uv_errors::Hinted::hints(err),
272            Self::MetadataLowering(err) => err.hints(),
273            _ => uv_errors::Hints::none(),
274        }
275    }
276}
277
278impl IsBuildBackendError for Error {
279    fn is_user_failure(&self) -> bool {
280        Self::is_user_failure(self)
281    }
282
283    fn is_build_backend_error(&self) -> bool {
284        match self {
285            Self::Build(err) => err.is_build_backend_error(),
286            _ => false,
287        }
288    }
289}
290
291impl Error {
292    /// Return whether this is an expected user-facing failure.
293    pub fn is_user_failure(&self) -> bool {
294        match self {
295            Self::NoBuild
296            | Self::NoBuildPackage(_)
297            | Self::InvalidUrl(_)
298            | Self::NonFileUrl(_)
299            | Self::WheelFilename(_)
300            | Self::WheelMetadataNameMismatch { .. }
301            | Self::WheelMetadataVersionMismatch { .. }
302            | Self::WheelFilenameNameMismatch { .. }
303            | Self::WheelFilenameVersionMismatch { .. }
304            | Self::BuiltWheelIncompatibleHostPlatform { .. }
305            | Self::BuiltWheelIncompatibleTargetPlatform { .. }
306            | Self::Metadata(_)
307            | Self::WheelMetadata(..)
308            | Self::Extract(..)
309            | Self::MissingPkgInfo
310            | Self::MissingSubdirectory(..)
311            | Self::MissingSourceDistGitLfsArtifacts(..)
312            | Self::MissingWheelGitLfsArtifacts(..)
313            | Self::PkgInfo(_)
314            | Self::MissingPyprojectToml
315            | Self::PyprojectToml(_)
316            | Self::MetadataLowering(_)
317            | Self::NotFound(_)
318            | Self::RequiresPython(..)
319            | Self::MismatchedHashes { .. }
320            | Self::MismatchedSize { .. }
321            | Self::MismatchedContentLength { .. }
322            | Self::MismatchedRangeSize { .. }
323            | Self::MissingHashes { .. }
324            | Self::MissingActualHashes { .. }
325            | Self::MissingExpectedHashes { .. }
326            | Self::HashesNotSupportedSourceTree(_)
327            | Self::HashesNotSupportedGit(_) => true,
328            Self::Build(error) => error.is_user_failure(),
329            Self::InstallWheelError(error) => error.is_user_failure(),
330            Self::Git(error) => error.is_user_failure(),
331            Self::Reqwest(error) => error.is_user_failure(),
332            Self::Client(error) => error.is_user_failure(),
333            Self::ClientBuild(error) => error.is_user_failure(),
334            Self::CacheRead(_)
335            | Self::CacheWrite(_)
336            | Self::CacheLock(_)
337            | Self::CacheDecode(_)
338            | Self::CacheEncode(_)
339            | Self::CacheWalk(_)
340            | Self::CacheInfo(_)
341            | Self::ReadInstalled(..)
342            | Self::CacheHeal(..)
343            | Self::BaseInterpreter(_)
344            | Self::Join(_)
345            | Self::HashExhaustion(_) => false,
346            Self::ReqwestMiddlewareError(error) => is_not_found_error(error.as_ref()),
347        }
348    }
349
350    /// Construct a hash mismatch error.
351    pub fn hash_mismatch(
352        distribution: String,
353        expected: &[HashDigest],
354        actual: &[HashDigest],
355    ) -> Self {
356        match (expected.is_empty(), actual.is_empty()) {
357            (true, true) => Self::MissingHashes { distribution },
358            (true, false) => {
359                let actual = actual
360                    .iter()
361                    .map(|hash| format!("  {hash}"))
362                    .collect::<Vec<_>>()
363                    .join("\n");
364
365                Self::MissingExpectedHashes {
366                    distribution,
367                    actual,
368                }
369            }
370            (false, true) => {
371                let expected = expected
372                    .iter()
373                    .map(|hash| format!("  {hash}"))
374                    .collect::<Vec<_>>()
375                    .join("\n");
376
377                Self::MissingActualHashes {
378                    distribution,
379                    expected,
380                }
381            }
382            (false, false) => {
383                let expected = expected
384                    .iter()
385                    .map(|hash| format!("  {hash}"))
386                    .collect::<Vec<_>>()
387                    .join("\n");
388
389                let actual = actual
390                    .iter()
391                    .map(|hash| format!("  {hash}"))
392                    .collect::<Vec<_>>()
393                    .join("\n");
394
395                Self::MismatchedHashes {
396                    distribution,
397                    expected,
398                    actual,
399                }
400            }
401        }
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::{Error, PythonVersion};
408    use std::str::FromStr;
409    use uv_distribution_filename::WheelFilename;
410    use uv_platform_tags::{Arch, Os, Platform};
411    use uv_python_types::PythonVariant;
412
413    #[test]
414    fn built_wheel_error_formats_freethreaded_python() {
415        let err = Error::BuiltWheelIncompatibleHostPlatform {
416            filename: WheelFilename::from_str(
417                "cryptography-47.0.0.dev1-cp315-abi3t-macosx_11_0_arm64.whl",
418            )
419            .unwrap(),
420            python_platform: Platform::new(
421                Os::Macos {
422                    major: 11,
423                    minor: 0,
424                },
425                Arch::Aarch64,
426            ),
427            python_version: PythonVersion {
428                version: (3, 15),
429                variant: PythonVariant::Freethreaded,
430            },
431        };
432
433        assert_eq!(
434            err.to_string(),
435            "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"
436        );
437    }
438
439    #[test]
440    fn built_wheel_error_formats_target_python() {
441        let err = Error::BuiltWheelIncompatibleTargetPlatform {
442            filename: WheelFilename::from_str("py313-0.1.0-py313-none-any.whl").unwrap(),
443            python_platform: Platform::new(
444                Os::Manylinux {
445                    major: 2,
446                    minor: 28,
447                },
448                Arch::X86_64,
449            ),
450            python_version: PythonVersion {
451                version: (3, 12),
452                variant: PythonVariant::Default,
453            },
454        };
455
456        assert_eq!(
457            err.to_string(),
458            "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."
459        );
460    }
461}