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