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