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    #[error(transparent)]
201    InstallWheelError(uv_install_wheel::Error),
202}
203
204impl From<reqwest::Error> for Error {
205    fn from(error: reqwest::Error) -> Self {
206        Self::Reqwest(WrappedReqwestError::from(error))
207    }
208}
209
210impl From<reqwest_middleware::Error> for Error {
211    fn from(error: reqwest_middleware::Error) -> Self {
212        match error {
213            reqwest_middleware::Error::Middleware(error) => Self::ReqwestMiddlewareError(error),
214            reqwest_middleware::Error::Reqwest(error) => {
215                Self::Reqwest(WrappedReqwestError::from(error))
216            }
217        }
218    }
219}
220
221impl IsBuildBackendError for Error {
222    fn is_build_backend_error(&self) -> bool {
223        match self {
224            Self::Build(err) => err.is_build_backend_error(),
225            _ => false,
226        }
227    }
228}
229
230impl Error {
231    /// Construct a hash mismatch error.
232    pub fn hash_mismatch(
233        distribution: String,
234        expected: &[HashDigest],
235        actual: &[HashDigest],
236    ) -> Self {
237        match (expected.is_empty(), actual.is_empty()) {
238            (true, true) => Self::MissingHashes { distribution },
239            (true, false) => {
240                let actual = actual
241                    .iter()
242                    .map(|hash| format!("  {hash}"))
243                    .collect::<Vec<_>>()
244                    .join("\n");
245
246                Self::MissingExpectedHashes {
247                    distribution,
248                    actual,
249                }
250            }
251            (false, true) => {
252                let expected = expected
253                    .iter()
254                    .map(|hash| format!("  {hash}"))
255                    .collect::<Vec<_>>()
256                    .join("\n");
257
258                Self::MissingActualHashes {
259                    distribution,
260                    expected,
261                }
262            }
263            (false, false) => {
264                let expected = expected
265                    .iter()
266                    .map(|hash| format!("  {hash}"))
267                    .collect::<Vec<_>>()
268                    .join("\n");
269
270                let actual = actual
271                    .iter()
272                    .map(|hash| format!("  {hash}"))
273                    .collect::<Vec<_>>()
274                    .join("\n");
275
276                Self::MismatchedHashes {
277                    distribution,
278                    expected,
279                    actual,
280                }
281            }
282        }
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::{Error, PythonVersion};
289    use std::str::FromStr;
290    use uv_distribution_filename::WheelFilename;
291    use uv_platform_tags::{Arch, Os, Platform};
292    use uv_python::PythonVariant;
293
294    #[test]
295    fn built_wheel_error_formats_freethreaded_python() {
296        let err = Error::BuiltWheelIncompatibleHostPlatform {
297            filename: WheelFilename::from_str(
298                "cryptography-47.0.0.dev1-cp315-abi3t-macosx_11_0_arm64.whl",
299            )
300            .unwrap(),
301            python_platform: Platform::new(
302                Os::Macos {
303                    major: 11,
304                    minor: 0,
305                },
306                Arch::Aarch64,
307            ),
308            python_version: PythonVersion {
309                version: (3, 15),
310                variant: PythonVariant::Freethreaded,
311            },
312        };
313
314        assert_eq!(
315            err.to_string(),
316            "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"
317        );
318    }
319
320    #[test]
321    fn built_wheel_error_formats_target_python() {
322        let err = Error::BuiltWheelIncompatibleTargetPlatform {
323            filename: WheelFilename::from_str("py313-0.1.0-py313-none-any.whl").unwrap(),
324            python_platform: Platform::new(
325                Os::Manylinux {
326                    major: 2,
327                    minor: 28,
328                },
329                Arch::X86_64,
330            ),
331            python_version: PythonVersion {
332                version: (3, 12),
333                variant: PythonVariant::Default,
334            },
335        };
336
337        assert_eq!(
338            err.to_string(),
339            "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."
340        );
341    }
342}