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