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_macros::DebugNoInline;
15use uv_normalize::PackageName;
16use uv_pep440::{Version, VersionSpecifiers};
17use uv_platform_tags::Platform;
18use uv_pypi_types::{HashAlgorithm, HashDigest};
19use uv_python_types::PythonVariant;
20use uv_redacted::SafeUrl;
21use uv_types::AnyErrorBuild;
22
23#[derive(Debug, Clone, Copy)]
24pub struct PythonVersion {
25    pub(crate) version: (u8, u8),
26    pub(crate) variant: PythonVariant,
27}
28
29impl fmt::Display for PythonVersion {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        let (major, minor) = self.version;
32        write!(f, "{major}.{minor}{}", self.variant.executable_suffix())
33    }
34}
35
36#[derive(DebugNoInline, thiserror::Error)]
37pub enum Error {
38    #[error("Wheel variants are not supported yet: `{0}`")]
39    UnsupportedWheelVariant(Box<WheelFilename>),
40    #[error("Wheel variant has unsupported properties: `{0}`")]
41    IncompatibleWheelVariant(Box<WheelFilename>),
42    #[error("Building source distributions is disabled")]
43    NoBuild,
44    #[error("Building source distributions for `{0}` is disabled")]
45    NoBuildPackage(PackageName),
46
47    // Network error
48    #[error(transparent)]
49    InvalidUrl(#[from] uv_distribution_types::ToUrlError),
50    #[error("Expected a file URL, but received: {0}")]
51    NonFileUrl(SafeUrl),
52    #[error(transparent)]
53    Git(#[from] uv_git::GitResolverError),
54    #[error(transparent)]
55    Reqwest(#[from] WrappedReqwestError),
56    #[error(transparent)]
57    Client(#[from] uv_client::Error),
58    #[error(transparent)]
59    ClientBuild(#[from] uv_client::ClientBuildError),
60
61    // Cache writing error
62    #[error("Failed to read from the distribution cache")]
63    CacheRead(#[source] std::io::Error),
64    #[error("Failed to write to the distribution cache")]
65    CacheWrite(#[source] std::io::Error),
66    #[error("Failed to acquire lock on the distribution cache")]
67    CacheLock(#[source] CacheError),
68    #[error("Failed to deserialize cache entry")]
69    CacheDecode(#[from] rmp_serde::decode::Error),
70    #[error("Failed to serialize cache entry")]
71    CacheEncode(#[from] rmp_serde::encode::Error),
72    #[error("Failed to walk the distribution cache")]
73    CacheWalk(#[source] walkdir::Error),
74    #[error(transparent)]
75    CacheInfo(#[from] uv_cache_info::CacheInfoError),
76
77    // Build error
78    #[error(transparent)]
79    Build(AnyErrorBuild),
80    #[error("Built wheel has an invalid filename")]
81    WheelFilename(#[from] WheelFilenameError),
82    #[error("Package metadata name `{metadata}` does not match given name `{given}`")]
83    WheelMetadataNameMismatch {
84        given: PackageName,
85        metadata: PackageName,
86    },
87    #[error("Package metadata version `{metadata}` does not match given version `{given}`")]
88    WheelMetadataVersionMismatch { given: Version, metadata: Version },
89    #[error(
90        "Package metadata name `{metadata}` does not match `{filename}` from the wheel filename"
91    )]
92    WheelFilenameNameMismatch {
93        filename: PackageName,
94        metadata: PackageName,
95    },
96    #[error(
97        "Package metadata version `{metadata}` does not match `{filename}` from the wheel filename"
98    )]
99    WheelFilenameVersionMismatch {
100        filename: Version,
101        metadata: Version,
102    },
103    /// This shouldn't happen, it's a bug in the build backend.
104    #[error(
105        "The built wheel `{}` is not compatible with the current Python {} on {}",
106        filename,
107        python_version,
108        python_platform.pretty(),
109    )]
110    BuiltWheelIncompatibleHostPlatform {
111        filename: WheelFilename,
112        python_platform: Platform,
113        python_version: PythonVersion,
114    },
115    /// This may happen when trying to cross-install native dependencies without their build backend
116    /// being aware that the target is a cross-install.
117    #[error(
118        "The built wheel `{}` is not compatible with the target Python {} on {}. Consider using `--no-build` to disable building wheels.",
119        filename,
120        python_version,
121        python_platform.pretty(),
122    )]
123    BuiltWheelIncompatibleTargetPlatform {
124        filename: WheelFilename,
125        python_platform: Platform,
126        python_version: PythonVersion,
127    },
128    #[error("Failed to parse metadata from built wheel")]
129    Metadata(#[from] uv_pypi_types::MetadataError),
130    #[error("Failed to read metadata: {}", _0.user_display())]
131    WheelMetadata(PathBuf, #[source] Box<uv_metadata::Error>),
132    #[error("Failed to read metadata from installed package `{0}`")]
133    ReadInstalled(Box<InstalledDist>, #[source] InstalledDistError),
134    #[error("Failed to extract archive: {0}")]
135    Extract(String, #[source] uv_extract::Error),
136    #[error("The source distribution is missing a `PKG-INFO` file")]
137    MissingPkgInfo,
138    #[error("The source distribution `{}` has no subdirectory `{}`", _0, _1.display())]
139    MissingSubdirectory(SafeUrl, PathBuf),
140    #[error("The source distribution `{0}` is missing Git LFS artifacts.")]
141    MissingSourceDistGitLfsArtifacts(SafeUrl, #[source] GitError),
142    #[error("The wheel `{0}` is missing Git LFS artifacts.")]
143    MissingWheelGitLfsArtifacts(SafeUrl, #[source] GitError),
144    #[error("Failed to extract static metadata from `PKG-INFO`")]
145    PkgInfo(#[source] uv_pypi_types::MetadataError),
146    #[error("The source distribution is missing a `pyproject.toml` file")]
147    MissingPyprojectToml,
148    #[error("Failed to extract static metadata from `pyproject.toml`")]
149    PyprojectToml(#[source] uv_pypi_types::MetadataError),
150    #[error(transparent)]
151    MetadataLowering(#[from] MetadataError),
152    #[error("Distribution not found at: {0}")]
153    NotFound(SafeUrl),
154    #[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())]
155    CacheHeal(String, HashAlgorithm),
156    #[error("The source distribution requires Python {0}, but {1} is installed")]
157    RequiresPython(VersionSpecifiers, Version),
158    #[error("Failed to identify base Python interpreter")]
159    BaseInterpreter(#[source] std::io::Error),
160
161    /// A generic request middleware error happened while making a request.
162    /// Refer to the error message for more details.
163    #[error(transparent)]
164    ReqwestMiddlewareError(#[from] anyhow::Error),
165
166    /// Should not occur; only seen when another task panicked.
167    #[error("The task executor is broken, did some other task panic?")]
168    Join(#[from] JoinError),
169
170    /// An I/O error that occurs while exhausting a reader to compute a hash.
171    #[error("Failed to hash distribution")]
172    HashExhaustion(#[source] std::io::Error),
173
174    #[error("Hash mismatch for `{distribution}`\n\nExpected:\n{expected}\n\nComputed:\n{actual}")]
175    MismatchedHashes {
176        distribution: String,
177        expected: String,
178        actual: String,
179    },
180
181    #[error(
182        "Size mismatch for `{distribution}`: expected {expected} bytes, but downloaded {actual} bytes"
183    )]
184    MismatchedSize {
185        distribution: String,
186        expected: u64,
187        actual: u64,
188    },
189
190    #[error(
191        "Content-Length mismatch for `{distribution}`: expected {expected} bytes, but the server advertised {actual} bytes"
192    )]
193    MismatchedContentLength {
194        distribution: String,
195        expected: u64,
196        actual: u64,
197    },
198
199    #[error(
200        "Range response size mismatch for `{distribution}`: expected {expected} bytes from Content-Range, but received {actual} bytes"
201    )]
202    MismatchedRangeSize {
203        distribution: String,
204        expected: u64,
205        actual: u64,
206    },
207
208    #[error(
209        "Hash-checking is enabled, but no hashes were provided or computed for: {distribution}"
210    )]
211    MissingHashes { distribution: String },
212
213    #[error(
214        "Hash-checking is enabled, but no hashes were computed for `{distribution}`\n\nExpected:\n{expected}"
215    )]
216    MissingActualHashes {
217        distribution: String,
218        expected: String,
219    },
220
221    #[error(
222        "Hash-checking is enabled, but no hashes were provided for `{distribution}`\n\nComputed:\n{actual}"
223    )]
224    MissingExpectedHashes {
225        distribution: String,
226        actual: String,
227    },
228
229    #[error("Hash-checking is not supported for local directories: {0}")]
230    HashesNotSupportedSourceTree(String),
231
232    #[error("Hash-checking is not supported for Git repositories: {0}")]
233    HashesNotSupportedGit(String),
234
235    #[error(transparent)]
236    InstallWheelError(uv_install_wheel::Error),
237}
238
239fn is_not_found_error(mut error: &(dyn std::error::Error + 'static)) -> bool {
240    loop {
241        if error
242            .downcast_ref::<reqwest::Error>()
243            .and_then(reqwest::Error::status)
244            .is_some_and(|status| status == reqwest::StatusCode::NOT_FOUND)
245        {
246            return true;
247        }
248        let Some(source) = error.source() else {
249            return false;
250        };
251        error = source;
252    }
253}
254
255impl From<reqwest::Error> for Error {
256    fn from(error: reqwest::Error) -> Self {
257        Self::Reqwest(WrappedReqwestError::from(error))
258    }
259}
260
261impl From<reqwest_middleware::Error> for Error {
262    fn from(error: reqwest_middleware::Error) -> Self {
263        match error {
264            reqwest_middleware::Error::Middleware(error) => Self::ReqwestMiddlewareError(error),
265            reqwest_middleware::Error::Reqwest(error) => {
266                Self::Reqwest(WrappedReqwestError::from(error))
267            }
268        }
269    }
270}
271
272impl uv_errors::Hinted for Error {
273    fn hints(&self) -> uv_errors::Hints<'_> {
274        match self {
275            Self::Build(err) => err.hints(),
276            Self::Client(err) => uv_errors::Hinted::hints(err),
277            Self::MetadataLowering(err) => err.hints(),
278            _ => uv_errors::Hints::none(),
279        }
280    }
281}
282
283impl IsBuildBackendError for Error {
284    fn is_user_failure(&self) -> bool {
285        Self::is_user_failure(self)
286    }
287
288    fn is_build_backend_error(&self) -> bool {
289        match self {
290            Self::Build(err) => err.is_build_backend_error(),
291            _ => false,
292        }
293    }
294}
295
296impl Error {
297    /// Return whether this is an expected user-facing failure.
298    pub fn is_user_failure(&self) -> bool {
299        match self {
300            Self::UnsupportedWheelVariant(_)
301            | Self::IncompatibleWheelVariant(_)
302            | Self::NoBuild
303            | Self::NoBuildPackage(_)
304            | Self::InvalidUrl(_)
305            | Self::NonFileUrl(_)
306            | Self::WheelFilename(_)
307            | Self::WheelMetadataNameMismatch { .. }
308            | Self::WheelMetadataVersionMismatch { .. }
309            | Self::WheelFilenameNameMismatch { .. }
310            | Self::WheelFilenameVersionMismatch { .. }
311            | Self::BuiltWheelIncompatibleHostPlatform { .. }
312            | Self::BuiltWheelIncompatibleTargetPlatform { .. }
313            | Self::Metadata(_)
314            | Self::WheelMetadata(..)
315            | Self::Extract(..)
316            | Self::MissingPkgInfo
317            | Self::MissingSubdirectory(..)
318            | Self::MissingSourceDistGitLfsArtifacts(..)
319            | Self::MissingWheelGitLfsArtifacts(..)
320            | Self::PkgInfo(_)
321            | Self::MissingPyprojectToml
322            | Self::PyprojectToml(_)
323            | Self::MetadataLowering(_)
324            | Self::NotFound(_)
325            | Self::RequiresPython(..)
326            | Self::MismatchedHashes { .. }
327            | Self::MismatchedSize { .. }
328            | Self::MismatchedContentLength { .. }
329            | Self::MismatchedRangeSize { .. }
330            | Self::MissingHashes { .. }
331            | Self::MissingActualHashes { .. }
332            | Self::MissingExpectedHashes { .. }
333            | Self::HashesNotSupportedSourceTree(_)
334            | Self::HashesNotSupportedGit(_) => true,
335            Self::Build(error) => error.is_user_failure(),
336            Self::InstallWheelError(error) => error.is_user_failure(),
337            Self::Git(error) => error.is_user_failure(),
338            Self::Reqwest(error) => error.is_user_failure(),
339            Self::Client(error) => error.is_user_failure(),
340            Self::ClientBuild(error) => error.is_user_failure(),
341            Self::CacheRead(_)
342            | Self::CacheWrite(_)
343            | Self::CacheLock(_)
344            | Self::CacheDecode(_)
345            | Self::CacheEncode(_)
346            | Self::CacheWalk(_)
347            | Self::CacheInfo(_)
348            | Self::ReadInstalled(..)
349            | Self::CacheHeal(..)
350            | Self::BaseInterpreter(_)
351            | Self::Join(_)
352            | Self::HashExhaustion(_) => false,
353            Self::ReqwestMiddlewareError(error) => is_not_found_error(error.as_ref()),
354        }
355    }
356
357    /// Construct a hash mismatch error.
358    pub fn hash_mismatch(
359        distribution: String,
360        expected: &[HashDigest],
361        actual: &[HashDigest],
362    ) -> Self {
363        match (expected.is_empty(), actual.is_empty()) {
364            (true, true) => Self::MissingHashes { distribution },
365            (true, false) => {
366                let actual = actual
367                    .iter()
368                    .map(|hash| format!("  {hash}"))
369                    .collect::<Vec<_>>()
370                    .join("\n");
371
372                Self::MissingExpectedHashes {
373                    distribution,
374                    actual,
375                }
376            }
377            (false, true) => {
378                let expected = expected
379                    .iter()
380                    .map(|hash| format!("  {hash}"))
381                    .collect::<Vec<_>>()
382                    .join("\n");
383
384                Self::MissingActualHashes {
385                    distribution,
386                    expected,
387                }
388            }
389            (false, false) => {
390                let expected = expected
391                    .iter()
392                    .map(|hash| format!("  {hash}"))
393                    .collect::<Vec<_>>()
394                    .join("\n");
395
396                let actual = actual
397                    .iter()
398                    .map(|hash| format!("  {hash}"))
399                    .collect::<Vec<_>>()
400                    .join("\n");
401
402                Self::MismatchedHashes {
403                    distribution,
404                    expected,
405                    actual,
406                }
407            }
408        }
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::{Error, PythonVersion};
415    use std::str::FromStr;
416    use uv_distribution_filename::WheelFilename;
417    use uv_platform_tags::{Arch, Os, Platform};
418    use uv_python_types::PythonVariant;
419
420    #[test]
421    fn built_wheel_error_formats_freethreaded_python() {
422        let err = Error::BuiltWheelIncompatibleHostPlatform {
423            filename: WheelFilename::from_str(
424                "cryptography-47.0.0.dev1-cp315-abi3t-macosx_11_0_arm64.whl",
425            )
426            .unwrap(),
427            python_platform: Platform::new(
428                Os::Macos {
429                    major: 11,
430                    minor: 0,
431                },
432                Arch::Aarch64,
433            ),
434            python_version: PythonVersion {
435                version: (3, 15),
436                variant: PythonVariant::Freethreaded,
437            },
438        };
439
440        assert_eq!(
441            err.to_string(),
442            "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"
443        );
444    }
445
446    #[test]
447    fn built_wheel_error_formats_target_python() {
448        let err = Error::BuiltWheelIncompatibleTargetPlatform {
449            filename: WheelFilename::from_str("py313-0.1.0-py313-none-any.whl").unwrap(),
450            python_platform: Platform::new(
451                Os::Manylinux {
452                    major: 2,
453                    minor: 28,
454                },
455                Arch::X86_64,
456            ),
457            python_version: PythonVersion {
458                version: (3, 12),
459                variant: PythonVariant::Default,
460            },
461        };
462
463        assert_eq!(
464            err.to_string(),
465            "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."
466        );
467    }
468}