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 #[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 #[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 #[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 #[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 #[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 #[error(transparent)]
159 ReqwestMiddlewareError(#[from] anyhow::Error),
160
161 #[error("The task executor is broken, did some other task panic?")]
163 Join(#[from] JoinError),
164
165 #[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 "Content-Length mismatch for `{distribution}`: expected {expected} bytes, but the server advertised {actual} bytes"
187 )]
188 MismatchedContentLength {
189 distribution: String,
190 expected: u64,
191 actual: u64,
192 },
193
194 #[error(
195 "Range response size mismatch for `{distribution}`: expected {expected} bytes from Content-Range, but received {actual} bytes"
196 )]
197 MismatchedRangeSize {
198 distribution: String,
199 expected: u64,
200 actual: u64,
201 },
202
203 #[error(
204 "Hash-checking is enabled, but no hashes were provided or computed for: `{distribution}`"
205 )]
206 MissingHashes { distribution: String },
207
208 #[error(
209 "Hash-checking is enabled, but no hashes were computed for: `{distribution}`\n\nExpected:\n{expected}"
210 )]
211 MissingActualHashes {
212 distribution: String,
213 expected: String,
214 },
215
216 #[error(
217 "Hash-checking is enabled, but no hashes were provided for: `{distribution}`\n\nComputed:\n{actual}"
218 )]
219 MissingExpectedHashes {
220 distribution: String,
221 actual: String,
222 },
223
224 #[error("Hash-checking is not supported for local directories: `{0}`")]
225 HashesNotSupportedSourceTree(String),
226
227 #[error("Hash-checking is not supported for Git repositories: `{0}`")]
228 HashesNotSupportedGit(String),
229
230 #[error(transparent)]
231 InstallWheelError(uv_install_wheel::Error),
232}
233
234fn is_not_found_error(mut error: &(dyn std::error::Error + 'static)) -> bool {
235 loop {
236 if error
237 .downcast_ref::<reqwest::Error>()
238 .and_then(reqwest::Error::status)
239 .is_some_and(|status| status == reqwest::StatusCode::NOT_FOUND)
240 {
241 return true;
242 }
243 let Some(source) = error.source() else {
244 return false;
245 };
246 error = source;
247 }
248}
249
250impl From<reqwest::Error> for Error {
251 fn from(error: reqwest::Error) -> Self {
252 Self::Reqwest(WrappedReqwestError::from(error))
253 }
254}
255
256impl From<reqwest_middleware::Error> for Error {
257 fn from(error: reqwest_middleware::Error) -> Self {
258 match error {
259 reqwest_middleware::Error::Middleware(error) => Self::ReqwestMiddlewareError(error),
260 reqwest_middleware::Error::Reqwest(error) => {
261 Self::Reqwest(WrappedReqwestError::from(error))
262 }
263 }
264 }
265}
266
267impl uv_errors::Hinted for Error {
268 fn hints(&self) -> uv_errors::Hints<'_> {
269 match self {
270 Self::Build(err) => err.hints(),
271 Self::Client(err) => uv_errors::Hinted::hints(err),
272 Self::MetadataLowering(err) => err.hints(),
273 _ => uv_errors::Hints::none(),
274 }
275 }
276}
277
278impl IsBuildBackendError for Error {
279 fn is_user_failure(&self) -> bool {
280 Self::is_user_failure(self)
281 }
282
283 fn is_build_backend_error(&self) -> bool {
284 match self {
285 Self::Build(err) => err.is_build_backend_error(),
286 _ => false,
287 }
288 }
289}
290
291impl Error {
292 pub fn is_user_failure(&self) -> bool {
294 match self {
295 Self::NoBuild
296 | Self::NoBuildPackage(_)
297 | Self::InvalidUrl(_)
298 | Self::NonFileUrl(_)
299 | Self::WheelFilename(_)
300 | Self::WheelMetadataNameMismatch { .. }
301 | Self::WheelMetadataVersionMismatch { .. }
302 | Self::WheelFilenameNameMismatch { .. }
303 | Self::WheelFilenameVersionMismatch { .. }
304 | Self::BuiltWheelIncompatibleHostPlatform { .. }
305 | Self::BuiltWheelIncompatibleTargetPlatform { .. }
306 | Self::Metadata(_)
307 | Self::WheelMetadata(..)
308 | Self::Extract(..)
309 | Self::MissingPkgInfo
310 | Self::MissingSubdirectory(..)
311 | Self::MissingSourceDistGitLfsArtifacts(..)
312 | Self::MissingWheelGitLfsArtifacts(..)
313 | Self::PkgInfo(_)
314 | Self::MissingPyprojectToml
315 | Self::PyprojectToml(_)
316 | Self::MetadataLowering(_)
317 | Self::NotFound(_)
318 | Self::RequiresPython(..)
319 | Self::MismatchedHashes { .. }
320 | Self::MismatchedSize { .. }
321 | Self::MismatchedContentLength { .. }
322 | Self::MismatchedRangeSize { .. }
323 | Self::MissingHashes { .. }
324 | Self::MissingActualHashes { .. }
325 | Self::MissingExpectedHashes { .. }
326 | Self::HashesNotSupportedSourceTree(_)
327 | Self::HashesNotSupportedGit(_) => true,
328 Self::Build(error) => error.is_user_failure(),
329 Self::InstallWheelError(error) => error.is_user_failure(),
330 Self::Git(error) => error.is_user_failure(),
331 Self::Reqwest(error) => error.is_user_failure(),
332 Self::Client(error) => error.is_user_failure(),
333 Self::ClientBuild(error) => error.is_user_failure(),
334 Self::CacheRead(_)
335 | Self::CacheWrite(_)
336 | Self::CacheLock(_)
337 | Self::CacheDecode(_)
338 | Self::CacheEncode(_)
339 | Self::CacheWalk(_)
340 | Self::CacheInfo(_)
341 | Self::ReadInstalled(..)
342 | Self::CacheHeal(..)
343 | Self::BaseInterpreter(_)
344 | Self::Join(_)
345 | Self::HashExhaustion(_) => false,
346 Self::ReqwestMiddlewareError(error) => is_not_found_error(error.as_ref()),
347 }
348 }
349
350 pub fn hash_mismatch(
352 distribution: String,
353 expected: &[HashDigest],
354 actual: &[HashDigest],
355 ) -> Self {
356 match (expected.is_empty(), actual.is_empty()) {
357 (true, true) => Self::MissingHashes { distribution },
358 (true, false) => {
359 let actual = actual
360 .iter()
361 .map(|hash| format!(" {hash}"))
362 .collect::<Vec<_>>()
363 .join("\n");
364
365 Self::MissingExpectedHashes {
366 distribution,
367 actual,
368 }
369 }
370 (false, true) => {
371 let expected = expected
372 .iter()
373 .map(|hash| format!(" {hash}"))
374 .collect::<Vec<_>>()
375 .join("\n");
376
377 Self::MissingActualHashes {
378 distribution,
379 expected,
380 }
381 }
382 (false, false) => {
383 let expected = expected
384 .iter()
385 .map(|hash| format!(" {hash}"))
386 .collect::<Vec<_>>()
387 .join("\n");
388
389 let actual = actual
390 .iter()
391 .map(|hash| format!(" {hash}"))
392 .collect::<Vec<_>>()
393 .join("\n");
394
395 Self::MismatchedHashes {
396 distribution,
397 expected,
398 actual,
399 }
400 }
401 }
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::{Error, PythonVersion};
408 use std::str::FromStr;
409 use uv_distribution_filename::WheelFilename;
410 use uv_platform_tags::{Arch, Os, Platform};
411 use uv_python::PythonVariant;
412
413 #[test]
414 fn built_wheel_error_formats_freethreaded_python() {
415 let err = Error::BuiltWheelIncompatibleHostPlatform {
416 filename: WheelFilename::from_str(
417 "cryptography-47.0.0.dev1-cp315-abi3t-macosx_11_0_arm64.whl",
418 )
419 .unwrap(),
420 python_platform: Platform::new(
421 Os::Macos {
422 major: 11,
423 minor: 0,
424 },
425 Arch::Aarch64,
426 ),
427 python_version: PythonVersion {
428 version: (3, 15),
429 variant: PythonVariant::Freethreaded,
430 },
431 };
432
433 assert_eq!(
434 err.to_string(),
435 "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"
436 );
437 }
438
439 #[test]
440 fn built_wheel_error_formats_target_python() {
441 let err = Error::BuiltWheelIncompatibleTargetPlatform {
442 filename: WheelFilename::from_str("py313-0.1.0-py313-none-any.whl").unwrap(),
443 python_platform: Platform::new(
444 Os::Manylinux {
445 major: 2,
446 minor: 28,
447 },
448 Arch::X86_64,
449 ),
450 python_version: PythonVersion {
451 version: (3, 12),
452 variant: PythonVariant::Default,
453 },
454 };
455
456 assert_eq!(
457 err.to_string(),
458 "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."
459 );
460 }
461}