Skip to main content

uv_distribution_types/
file.rs

1use std::borrow::Cow;
2use std::fmt::{self, Display, Formatter};
3use std::str::FromStr;
4use std::sync::Arc;
5
6use jiff::Timestamp;
7use serde::{Deserialize, Serialize};
8
9use uv_pep440::{VersionSpecifiers, VersionSpecifiersParseError};
10use uv_pep508::split_scheme;
11use uv_pypi_types::{CoreMetadata, HashDigests, Yanked};
12use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError};
13use uv_small_str::SmallString;
14
15/// Error converting [`uv_pypi_types::PypiFile`] to [`distribution_type::File`].
16#[derive(Debug, thiserror::Error)]
17pub enum FileConversionError {
18    #[error("Failed to parse `requires-python`: `{0}`")]
19    RequiresPython(String, #[source] VersionSpecifiersParseError),
20}
21
22/// Internal analog to [`uv_pypi_types::PypiFile`].
23#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
24#[rkyv(derive(Debug))]
25pub struct File {
26    pub dist_info_metadata: bool,
27    pub filename: SmallString,
28    pub hashes: HashDigests,
29    pub requires_python: Option<Arc<VersionSpecifiers>>,
30    pub size: Option<u64>,
31    // N.B. We don't use a Jiff timestamp here because it's a little
32    // annoying to do so with rkyv. Since we only use this field for doing
33    // comparisons in testing, we just store it as a UTC timestamp in
34    // milliseconds.
35    pub upload_time_utc_ms: Option<i64>,
36    pub url: FileLocation,
37    pub yanked: Option<Box<Yanked>>,
38    /// Deprecated pyx-specific zstd wheel metadata, retained only for compatibility with the
39    /// flat-index cache layout.
40    // TODO: Remove this field when the flat-index cache format is next bumped.
41    pub zstd: Option<Box<Zstd>>,
42}
43
44impl File {
45    /// `TryFrom` instead of `From` to filter out files with invalid requires python version specifiers
46    pub fn try_from_pypi(
47        file: uv_pypi_types::PypiFile,
48        base: &SmallString,
49    ) -> Result<Self, FileConversionError> {
50        Ok(Self {
51            dist_info_metadata: file
52                .core_metadata
53                .as_ref()
54                .is_some_and(CoreMetadata::is_available),
55            filename: file.filename,
56            hashes: HashDigests::from(file.hashes),
57            requires_python: file
58                .requires_python
59                .transpose()
60                .map_err(|err| FileConversionError::RequiresPython(err.line().clone(), err))?,
61            size: file.size,
62            upload_time_utc_ms: file.upload_time.map(Timestamp::as_millisecond),
63            url: FileLocation::new(file.url, base),
64            yanked: file.yanked,
65            zstd: None,
66        })
67    }
68}
69
70/// While a registry file is generally a remote URL, it can also be a file if it comes from a directory flat indexes.
71#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
72#[rkyv(derive(Debug))]
73pub enum FileLocation {
74    /// URL relative to the base URL.
75    RelativeUrl(SmallString, SmallString),
76    /// Absolute URL.
77    AbsoluteUrl(UrlString),
78}
79
80impl FileLocation {
81    /// Parse a relative or absolute URL on a page with a base URL.
82    ///
83    /// This follows the HTML semantics where a link on a page is resolved relative to the URL of
84    /// that page.
85    pub fn new(url: SmallString, base: &SmallString) -> Self {
86        match split_scheme(&url) {
87            Some(..) => Self::AbsoluteUrl(UrlString::new(url)),
88            None => Self::RelativeUrl(base.clone(), url),
89        }
90    }
91
92    /// Returns the final raw URL path component after removing any query or fragment.
93    ///
94    /// The filename is not percent-decoded.
95    pub fn raw_filename(&self) -> &str {
96        let path = match self {
97            Self::RelativeUrl(_, path) => path.as_ref(),
98            Self::AbsoluteUrl(url) => url.as_ref(),
99        };
100        let path = path.split_once(['?', '#']).map_or(path, |(path, _)| path);
101        path.rsplit_once('/').map_or(path, |(_, filename)| filename)
102    }
103
104    /// Convert this location to a URL.
105    ///
106    /// A relative URL has its base joined to the path. An absolute URL is
107    /// parsed as-is. And a path location is turned into a URL via the `file`
108    /// protocol.
109    ///
110    /// # Errors
111    ///
112    /// This returns an error if any of the URL parsing fails, or if, for
113    /// example, the location is a path and the path isn't valid UTF-8.
114    /// (Because URLs must be valid UTF-8.)
115    pub fn to_url(&self) -> Result<DisplaySafeUrl, ToUrlError> {
116        match self {
117            Self::RelativeUrl(base, path) => {
118                let base_url =
119                    DisplaySafeUrl::parse(base).map_err(|err| ToUrlError::InvalidBase {
120                        base: base.to_string(),
121                        err,
122                    })?;
123                let joined = base_url.join(path).map_err(|err| ToUrlError::InvalidJoin {
124                    base: base.to_string(),
125                    path: path.to_string(),
126                    err,
127                })?;
128                Ok(joined)
129            }
130            Self::AbsoluteUrl(absolute) => absolute.to_url(),
131        }
132    }
133}
134
135impl Display for FileLocation {
136    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
137        match self {
138            Self::RelativeUrl(_base, url) => Display::fmt(&url, f),
139            Self::AbsoluteUrl(url) => Display::fmt(&url.0, f),
140        }
141    }
142}
143
144/// A [`Url`] represented as a `String`.
145///
146/// This type is not guaranteed to be a valid URL, and may error on conversion.
147#[derive(
148    Debug,
149    Clone,
150    PartialEq,
151    Eq,
152    PartialOrd,
153    Ord,
154    Hash,
155    Serialize,
156    Deserialize,
157    rkyv::Archive,
158    rkyv::Deserialize,
159    rkyv::Serialize,
160)]
161#[serde(transparent)]
162#[rkyv(derive(Debug))]
163pub struct UrlString(SmallString);
164
165impl UrlString {
166    /// Create a new [`UrlString`] from a [`String`].
167    fn new(url: SmallString) -> Self {
168        Self(url)
169    }
170
171    /// Converts a [`UrlString`] to a [`DisplaySafeUrl`].
172    pub fn to_url(&self) -> Result<DisplaySafeUrl, ToUrlError> {
173        DisplaySafeUrl::from_str(&self.0).map_err(|err| ToUrlError::InvalidAbsolute {
174            absolute: self.0.to_string(),
175            err,
176        })
177    }
178
179    /// Return the [`UrlString`] with any query parameters and fragments removed.
180    pub fn base_str(&self) -> &str {
181        self.as_ref()
182            .split_once('?')
183            .or_else(|| self.as_ref().split_once('#'))
184            .map(|(path, _)| path)
185            .unwrap_or(self.as_ref())
186    }
187
188    /// Return the [`UrlString`] (as a [`Cow`]) with any fragments removed.
189    #[must_use]
190    pub fn without_fragment(&self) -> Cow<'_, Self> {
191        self.as_ref()
192            .split_once('#')
193            .map(|(path, _)| Cow::Owned(Self(SmallString::from(path))))
194            .unwrap_or(Cow::Borrowed(self))
195    }
196}
197
198impl AsRef<str> for UrlString {
199    fn as_ref(&self) -> &str {
200        &self.0
201    }
202}
203
204impl From<DisplaySafeUrl> for UrlString {
205    fn from(value: DisplaySafeUrl) -> Self {
206        Self(value.as_str().into())
207    }
208}
209
210impl From<&DisplaySafeUrl> for UrlString {
211    fn from(value: &DisplaySafeUrl) -> Self {
212        Self(value.as_str().into())
213    }
214}
215
216impl Display for UrlString {
217    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
218        fmt::Display::fmt(&self.0, f)
219    }
220}
221
222/// An error that occurs when a [`FileLocation`] is not a valid URL.
223#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
224pub enum ToUrlError {
225    /// An error that occurs when the base URL in [`FileLocation::Relative`]
226    /// could not be parsed as a valid URL.
227    #[error("Could not parse base URL `{base}` as a valid URL")]
228    InvalidBase {
229        /// The base URL that could not be parsed as a valid URL.
230        base: String,
231        /// The underlying URL parse error.
232        #[source]
233        err: DisplaySafeUrlError,
234    },
235    /// An error that occurs when the base URL could not be joined with
236    /// the relative path in a [`FileLocation::Relative`].
237    #[error("Could not join base URL `{base}` to relative path `{path}`")]
238    InvalidJoin {
239        /// The base URL that could not be parsed as a valid URL.
240        base: String,
241        /// The relative path segment.
242        path: String,
243        /// The underlying URL parse error.
244        #[source]
245        err: DisplaySafeUrlError,
246    },
247    /// An error that occurs when the absolute URL in [`FileLocation::Absolute`]
248    /// could not be parsed as a valid URL.
249    #[error("Could not parse absolute URL `{absolute}` as a valid URL")]
250    InvalidAbsolute {
251        /// The absolute URL that could not be parsed as a valid URL.
252        absolute: String,
253        /// The underlying URL parse error.
254        #[source]
255        err: DisplaySafeUrlError,
256    },
257}
258
259/// Deprecated pyx-specific zstd wheel metadata, retained only for compatibility with existing
260/// cache layouts.
261// TODO: Remove this type once the Simple API and flat-index cache formats are both bumped.
262#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
263pub struct Zstd {
264    pub hashes: HashDigests,
265    pub size: Option<u64>,
266}
267
268#[cfg(test)]
269mod tests {
270    use std::assert_matches;
271
272    use super::*;
273
274    #[test]
275    fn raw_filename() {
276        let base = SmallString::from("https://example.com/simple/");
277
278        let location = FileLocation::new(
279            SmallString::from("files/example%20pkg.whl?download=1#fragment"),
280            &base,
281        );
282        assert_eq!(location.raw_filename(), "example%20pkg.whl");
283
284        let location = FileLocation::new(
285            SmallString::from("https://files.example.com/example.whl#sha256=digest"),
286            &base,
287        );
288        assert_eq!(location.raw_filename(), "example.whl");
289    }
290
291    #[test]
292    fn base_str() {
293        let url = UrlString("https://example.com/path?query#fragment".into());
294        assert_eq!(url.base_str(), "https://example.com/path");
295
296        let url = UrlString("https://example.com/path#fragment".into());
297        assert_eq!(url.base_str(), "https://example.com/path");
298
299        let url = UrlString("https://example.com/path".into());
300        assert_eq!(url.base_str(), "https://example.com/path");
301    }
302
303    #[test]
304    fn without_fragment() {
305        // Borrows a URL without a fragment
306        let url = UrlString("https://example.com/path".into());
307        assert_eq!(&*url.without_fragment(), &url);
308        assert_matches!(url.without_fragment(), Cow::Borrowed(_));
309
310        // Removes the fragment if present on the URL
311        let url = UrlString("https://example.com/path?query#fragment".into());
312        assert_eq!(
313            &*url.without_fragment(),
314            &UrlString("https://example.com/path?query".into())
315        );
316        assert_matches!(url.without_fragment(), Cow::Owned(_));
317    }
318}