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::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        "Hash-checking is enabled, but no hashes were provided or computed for: `{distribution}`"
187    )]
188    MissingHashes { distribution: String },
189
190    #[error(
191        "Hash-checking is enabled, but no hashes were computed for: `{distribution}`\n\nExpected:\n{expected}"
192    )]
193    MissingActualHashes {
194        distribution: String,
195        expected: String,
196    },
197
198    #[error(
199        "Hash-checking is enabled, but no hashes were provided for: `{distribution}`\n\nComputed:\n{actual}"
200    )]
201    MissingExpectedHashes {
202        distribution: String,
203        actual: String,
204    },
205
206    #[error("Hash-checking is not supported for local directories: `{0}`")]
207    HashesNotSupportedSourceTree(String),
208
209    #[error("Hash-checking is not supported for Git repositories: `{0}`")]
210    HashesNotSupportedGit(String),
211
212    #[error(transparent)]
213    InstallWheelError(uv_install_wheel::Error),
214}
215
216impl From<reqwest::Error> for Error {
217    fn from(error: reqwest::Error) -> Self {
218        Self::Reqwest(WrappedReqwestError::from(error))
219    }
220}
221
222impl From<reqwest_middleware::Error> for Error {
223    fn from(error: reqwest_middleware::Error) -> Self {
224        match error {
225            reqwest_middleware::Error::Middleware(error) => Self::ReqwestMiddlewareError(error),
226            reqwest_middleware::Error::Reqwest(error) => {
227                Self::Reqwest(WrappedReqwestError::from(error))
228            }
229        }
230    }
231}
232
233impl uv_errors::Hint for Error {
234    fn hints(&self) -> uv_errors::Hints<'_> {
235        match self {
236            Self::Build(err) => err.hints(),
237            Self::Client(err) => uv_errors::Hint::hints(err),
238            Self::MetadataLowering(err) => err.hints(),
239            _ => uv_errors::Hints::none(),
240        }
241    }
242}
243
244impl IsBuildBackendError for Error {
245    fn is_build_backend_error(&self) -> bool {
246        match self {
247            Self::Build(err) => err.is_build_backend_error(),
248            _ => false,
249        }
250    }
251}
252
253impl Error {
254    /// Construct a hash mismatch error.
255    pub fn hash_mismatch(
256        distribution: String,
257        expected: &[HashDigest],
258        actual: &[HashDigest],
259    ) -> Self {
260        match (expected.is_empty(), actual.is_empty()) {
261            (true, true) => Self::MissingHashes { distribution },
262            (true, false) => {
263                let actual = actual
264                    .iter()
265                    .map(|hash| format!("  {hash}"))
266                    .collect::<Vec<_>>()
267                    .join("\n");
268
269                Self::MissingExpectedHashes {
270                    distribution,
271                    actual,
272                }
273            }
274            (false, true) => {
275                let expected = expected
276                    .iter()
277                    .map(|hash| format!("  {hash}"))
278                    .collect::<Vec<_>>()
279                    .join("\n");
280
281                Self::MissingActualHashes {
282                    distribution,
283                    expected,
284                }
285            }
286            (false, false) => {
287                let expected = expected
288                    .iter()
289                    .map(|hash| format!("  {hash}"))
290                    .collect::<Vec<_>>()
291                    .join("\n");
292
293                let actual = actual
294                    .iter()
295                    .map(|hash| format!("  {hash}"))
296                    .collect::<Vec<_>>()
297                    .join("\n");
298
299                Self::MismatchedHashes {
300                    distribution,
301                    expected,
302                    actual,
303                }
304            }
305        }
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::{Error, PythonVersion};
312    use std::str::FromStr;
313    use uv_distribution_filename::WheelFilename;
314    use uv_platform_tags::{Arch, Os, Platform};
315    use uv_python::PythonVariant;
316
317    #[test]
318    fn built_wheel_error_formats_freethreaded_python() {
319        let err = Error::BuiltWheelIncompatibleHostPlatform {
320            filename: WheelFilename::from_str(
321                "cryptography-47.0.0.dev1-cp315-abi3t-macosx_11_0_arm64.whl",
322            )
323            .unwrap(),
324            python_platform: Platform::new(
325                Os::Macos {
326                    major: 11,
327                    minor: 0,
328                },
329                Arch::Aarch64,
330            ),
331            python_version: PythonVersion {
332                version: (3, 15),
333                variant: PythonVariant::Freethreaded,
334            },
335        };
336
337        assert_eq!(
338            err.to_string(),
339            "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"
340        );
341    }
342
343    #[test]
344    fn built_wheel_error_formats_target_python() {
345        let err = Error::BuiltWheelIncompatibleTargetPlatform {
346            filename: WheelFilename::from_str("py313-0.1.0-py313-none-any.whl").unwrap(),
347            python_platform: Platform::new(
348                Os::Manylinux {
349                    major: 2,
350                    minor: 28,
351                },
352                Arch::X86_64,
353            ),
354            python_version: PythonVersion {
355                version: (3, 12),
356                variant: PythonVariant::Default,
357            },
358        };
359
360        assert_eq!(
361            err.to_string(),
362            "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."
363        );
364    }
365}