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