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