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