Skip to main content

uv_distribution/
error.rs

1use std::fmt;
2use std::path::PathBuf;
3
4use owo_colors::OwoColorize;
5use tokio::task::JoinError;
6use zip::result::ZipError;
7
8use crate::metadata::MetadataError;
9use uv_cache::Error as CacheError;
10use uv_client::WrappedReqwestError;
11use uv_distribution_filename::{WheelFilename, WheelFilenameError};
12use uv_distribution_types::{InstalledDist, InstalledDistError, IsBuildBackendError};
13use uv_fs::Simplified;
14use uv_git::GitError;
15use uv_normalize::PackageName;
16use uv_pep440::{Version, VersionSpecifiers};
17use uv_platform_tags::Platform;
18use uv_pypi_types::{HashAlgorithm, HashDigest};
19use uv_python::PythonVariant;
20use uv_redacted::DisplaySafeUrl;
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(Debug, thiserror::Error)]
37pub enum Error {
38    #[error("Building source distributions is disabled")]
39    NoBuild,
40    #[error("Building source distributions for `{0}` is disabled")]
41    NoBuildPackage(PackageName),
42
43    // Network error
44    #[error(transparent)]
45    InvalidUrl(#[from] uv_distribution_types::ToUrlError),
46    #[error("Expected a file URL, but received: {0}")]
47    NonFileUrl(DisplaySafeUrl),
48    #[error(transparent)]
49    Git(#[from] uv_git::GitResolverError),
50    #[error(transparent)]
51    Reqwest(#[from] WrappedReqwestError),
52    #[error(transparent)]
53    Client(#[from] uv_client::Error),
54
55    // Cache writing error
56    #[error("Failed to read from the distribution cache")]
57    CacheRead(#[source] std::io::Error),
58    #[error("Failed to write to the distribution cache")]
59    CacheWrite(#[source] std::io::Error),
60    #[error("Failed to acquire lock on the distribution cache")]
61    CacheLock(#[source] CacheError),
62    #[error("Failed to deserialize cache entry")]
63    CacheDecode(#[from] rmp_serde::decode::Error),
64    #[error("Failed to serialize cache entry")]
65    CacheEncode(#[from] rmp_serde::encode::Error),
66    #[error("Failed to walk the distribution cache")]
67    CacheWalk(#[source] walkdir::Error),
68    #[error(transparent)]
69    CacheInfo(#[from] uv_cache_info::CacheInfoError),
70
71    // Build error
72    #[error(transparent)]
73    Build(AnyErrorBuild),
74    #[error("Built wheel has an invalid filename")]
75    WheelFilename(#[from] WheelFilenameError),
76    #[error("Package metadata name `{metadata}` does not match given name `{given}`")]
77    WheelMetadataNameMismatch {
78        given: PackageName,
79        metadata: PackageName,
80    },
81    #[error("Package metadata version `{metadata}` does not match given version `{given}`")]
82    WheelMetadataVersionMismatch { given: Version, metadata: Version },
83    #[error(
84        "Package metadata name `{metadata}` does not match `{filename}` from the wheel filename"
85    )]
86    WheelFilenameNameMismatch {
87        filename: PackageName,
88        metadata: PackageName,
89    },
90    #[error(
91        "Package metadata version `{metadata}` does not match `{filename}` from the wheel filename"
92    )]
93    WheelFilenameVersionMismatch {
94        filename: Version,
95        metadata: Version,
96    },
97    /// This shouldn't happen, it's a bug in the build backend.
98    #[error(
99        "The built wheel `{}` is not compatible with the current Python {} on {}",
100        filename,
101        python_version,
102        python_platform.pretty(),
103    )]
104    BuiltWheelIncompatibleHostPlatform {
105        filename: WheelFilename,
106        python_platform: Platform,
107        python_version: PythonVersion,
108    },
109    /// This may happen when trying to cross-install native dependencies without their build backend
110    /// being aware that the target is a cross-install.
111    #[error(
112        "The built wheel `{}` is not compatible with the target Python {} on {}. Consider using `--no-build` to disable building wheels.",
113        filename,
114        python_version,
115        python_platform.pretty(),
116    )]
117    BuiltWheelIncompatibleTargetPlatform {
118        filename: WheelFilename,
119        python_platform: Platform,
120        python_version: PythonVersion,
121    },
122    #[error("Failed to parse metadata from built wheel")]
123    Metadata(#[from] uv_pypi_types::MetadataError),
124    #[error("Failed to read metadata: `{}`", _0.user_display())]
125    WheelMetadata(PathBuf, #[source] Box<uv_metadata::Error>),
126    #[error("Failed to read metadata from installed package `{0}`")]
127    ReadInstalled(Box<InstalledDist>, #[source] InstalledDistError),
128    #[error("Failed to read zip archive from built wheel")]
129    Zip(#[from] ZipError),
130    #[error("Failed to extract archive: {0}")]
131    Extract(String, #[source] uv_extract::Error),
132    #[error("The source distribution is missing a `PKG-INFO` file")]
133    MissingPkgInfo,
134    #[error("The source distribution `{}` has no subdirectory `{}`", _0, _1.display())]
135    MissingSubdirectory(DisplaySafeUrl, PathBuf),
136    #[error("The source distribution `{0}` is missing Git LFS artifacts.")]
137    MissingGitLfsArtifacts(DisplaySafeUrl, #[source] GitError),
138    #[error("Failed to extract static metadata from `PKG-INFO`")]
139    PkgInfo(#[source] uv_pypi_types::MetadataError),
140    #[error("The source distribution is missing a `pyproject.toml` file")]
141    MissingPyprojectToml,
142    #[error("Failed to extract static metadata from `pyproject.toml`")]
143    PyprojectToml(#[source] uv_pypi_types::MetadataError),
144    #[error(transparent)]
145    MetadataLowering(#[from] MetadataError),
146    #[error("Distribution not found at: {0}")]
147    NotFound(DisplaySafeUrl),
148    #[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())]
149    CacheHeal(String, HashAlgorithm),
150    #[error("The source distribution requires Python {0}, but {1} is installed")]
151    RequiresPython(VersionSpecifiers, Version),
152    #[error("Failed to identify base Python interpreter")]
153    BaseInterpreter(#[source] std::io::Error),
154
155    /// A generic request middleware error happened while making a request.
156    /// Refer to the error message for more details.
157    #[error(transparent)]
158    ReqwestMiddlewareError(#[from] anyhow::Error),
159
160    /// Should not occur; only seen when another task panicked.
161    #[error("The task executor is broken, did some other task panic?")]
162    Join(#[from] JoinError),
163
164    /// An I/O error that occurs while exhausting a reader to compute a hash.
165    #[error("Failed to hash distribution")]
166    HashExhaustion(#[source] std::io::Error),
167
168    #[error("Hash mismatch for `{distribution}`\n\nExpected:\n{expected}\n\nComputed:\n{actual}")]
169    MismatchedHashes {
170        distribution: String,
171        expected: String,
172        actual: String,
173    },
174
175    #[error(
176        "Hash-checking is enabled, but no hashes were provided or computed for: `{distribution}`"
177    )]
178    MissingHashes { distribution: String },
179
180    #[error(
181        "Hash-checking is enabled, but no hashes were computed for: `{distribution}`\n\nExpected:\n{expected}"
182    )]
183    MissingActualHashes {
184        distribution: String,
185        expected: String,
186    },
187
188    #[error(
189        "Hash-checking is enabled, but no hashes were provided for: `{distribution}`\n\nComputed:\n{actual}"
190    )]
191    MissingExpectedHashes {
192        distribution: String,
193        actual: String,
194    },
195
196    #[error("Hash-checking is not supported for local directories: `{0}`")]
197    HashesNotSupportedSourceTree(String),
198
199    #[error("Hash-checking is not supported for Git repositories: `{0}`")]
200    HashesNotSupportedGit(String),
201
202    #[error(transparent)]
203    InstallWheelError(uv_install_wheel::Error),
204}
205
206impl From<reqwest::Error> for Error {
207    fn from(error: reqwest::Error) -> Self {
208        Self::Reqwest(WrappedReqwestError::from(error))
209    }
210}
211
212impl From<reqwest_middleware::Error> for Error {
213    fn from(error: reqwest_middleware::Error) -> Self {
214        match error {
215            reqwest_middleware::Error::Middleware(error) => Self::ReqwestMiddlewareError(error),
216            reqwest_middleware::Error::Reqwest(error) => {
217                Self::Reqwest(WrappedReqwestError::from(error))
218            }
219        }
220    }
221}
222
223impl IsBuildBackendError for Error {
224    fn is_build_backend_error(&self) -> bool {
225        match self {
226            Self::Build(err) => err.is_build_backend_error(),
227            _ => false,
228        }
229    }
230}
231
232impl Error {
233    /// Construct a hash mismatch error.
234    pub fn hash_mismatch(
235        distribution: String,
236        expected: &[HashDigest],
237        actual: &[HashDigest],
238    ) -> Self {
239        match (expected.is_empty(), actual.is_empty()) {
240            (true, true) => Self::MissingHashes { distribution },
241            (true, false) => {
242                let actual = actual
243                    .iter()
244                    .map(|hash| format!("  {hash}"))
245                    .collect::<Vec<_>>()
246                    .join("\n");
247
248                Self::MissingExpectedHashes {
249                    distribution,
250                    actual,
251                }
252            }
253            (false, true) => {
254                let expected = expected
255                    .iter()
256                    .map(|hash| format!("  {hash}"))
257                    .collect::<Vec<_>>()
258                    .join("\n");
259
260                Self::MissingActualHashes {
261                    distribution,
262                    expected,
263                }
264            }
265            (false, false) => {
266                let expected = expected
267                    .iter()
268                    .map(|hash| format!("  {hash}"))
269                    .collect::<Vec<_>>()
270                    .join("\n");
271
272                let actual = actual
273                    .iter()
274                    .map(|hash| format!("  {hash}"))
275                    .collect::<Vec<_>>()
276                    .join("\n");
277
278                Self::MismatchedHashes {
279                    distribution,
280                    expected,
281                    actual,
282                }
283            }
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::{Error, PythonVersion};
291    use std::str::FromStr;
292    use uv_distribution_filename::WheelFilename;
293    use uv_platform_tags::{Arch, Os, Platform};
294    use uv_python::PythonVariant;
295
296    #[test]
297    fn built_wheel_error_formats_freethreaded_python() {
298        let err = Error::BuiltWheelIncompatibleHostPlatform {
299            filename: WheelFilename::from_str(
300                "cryptography-47.0.0.dev1-cp315-abi3t-macosx_11_0_arm64.whl",
301            )
302            .unwrap(),
303            python_platform: Platform::new(
304                Os::Macos {
305                    major: 11,
306                    minor: 0,
307                },
308                Arch::Aarch64,
309            ),
310            python_version: PythonVersion {
311                version: (3, 15),
312                variant: PythonVariant::Freethreaded,
313            },
314        };
315
316        assert_eq!(
317            err.to_string(),
318            "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"
319        );
320    }
321
322    #[test]
323    fn built_wheel_error_formats_target_python() {
324        let err = Error::BuiltWheelIncompatibleTargetPlatform {
325            filename: WheelFilename::from_str("py313-0.1.0-py313-none-any.whl").unwrap(),
326            python_platform: Platform::new(
327                Os::Manylinux {
328                    major: 2,
329                    minor: 28,
330                },
331                Arch::X86_64,
332            ),
333            python_version: PythonVersion {
334                version: (3, 12),
335                variant: PythonVariant::Default,
336            },
337        };
338
339        assert_eq!(
340            err.to_string(),
341            "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."
342        );
343    }
344}