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 #[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 #[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 #[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 #[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 #[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 #[error(transparent)]
161 ReqwestMiddlewareError(#[from] anyhow::Error),
162
163 #[error("The task executor is broken, did some other task panic?")]
165 Join(#[from] JoinError),
166
167 #[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 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 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}