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