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    /// Hashes for separately available metadata, or an empty list when no hashes were provided.
27    pub dist_info_metadata: Option<HashDigests>,
28    pub filename: SmallString,
29    pub hashes: HashDigests,
30    pub requires_python: Option<Arc<VersionSpecifiers>>,
31    pub size: Option<u64>,
32    // N.B. We don't use a Jiff timestamp here because it's a little
33    // annoying to do so with rkyv. Since we only use this field for doing
34    // comparisons in testing, we just store it as a UTC timestamp in
35    // milliseconds.
36    pub upload_time_utc_ms: Option<i64>,
37    pub url: FileLocation,
38    pub yanked: Option<Box<Yanked>>,
39}
40
41impl File {
42    /// `TryFrom` instead of `From` to filter out files with invalid requires python version specifiers
43    pub fn try_from_pypi(
44        file: uv_pypi_types::PypiFile,
45        base: &SmallString,
46    ) -> Result<Self, FileConversionError> {
47        Ok(Self {
48            dist_info_metadata: Self::dist_info_metadata(file.core_metadata),
49            filename: file.filename,
50            hashes: HashDigests::from(file.hashes),
51            requires_python: file
52                .requires_python
53                .transpose()
54                .map_err(|err| FileConversionError::RequiresPython(err.line().clone(), err))?,
55            size: file.size,
56            upload_time_utc_ms: file.upload_time.map(Timestamp::as_millisecond),
57            url: FileLocation::new(file.url, base),
58            yanked: file.yanked,
59        })
60    }
61
62    fn dist_info_metadata(metadata: Option<CoreMetadata>) -> Option<HashDigests> {
63        match metadata? {
64            CoreMetadata::Bool(false) => None,
65            CoreMetadata::Bool(true) => Some(HashDigests::empty()),
66            CoreMetadata::Hashes(hashes) => Some(HashDigests::from(hashes)),
67        }
68    }
69}
70
71/// While a registry file is generally a remote URL, it can also be a file if it comes from a directory flat indexes.
72#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
73#[rkyv(derive(Debug))]
74pub enum FileLocation {
75    /// URL relative to the base URL.
76    RelativeUrl(SmallString, SmallString),
77    /// Absolute URL.
78    AbsoluteUrl(UrlString),
79}
80
81impl FileLocation {
82    /// Parse a relative or absolute URL on a page with a base URL.
83    ///
84    /// This follows the HTML semantics where a link on a page is resolved relative to the URL of
85    /// that page.
86    pub fn new(url: SmallString, base: &SmallString) -> Self {
87        match split_scheme(&url) {
88            Some(..) => Self::AbsoluteUrl(UrlString::new(url)),
89            None => Self::RelativeUrl(base.clone(), url),
90        }
91    }
92
93    /// Returns the final raw URL path component after removing any query or fragment.
94    ///
95    /// The filename is not percent-decoded.
96    pub fn raw_filename(&self) -> &str {
97        let path = match self {
98            Self::RelativeUrl(_, path) => path.as_ref(),
99            Self::AbsoluteUrl(url) => url.as_ref(),
100        };
101        let path = path.split_once(['?', '#']).map_or(path, |(path, _)| path);
102        path.rsplit_once('/').map_or(path, |(_, filename)| filename)
103    }
104
105    /// Convert this location to a URL.
106    ///
107    /// A relative URL has its base joined to the path. An absolute URL is
108    /// parsed as-is. And a path location is turned into a URL via the `file`
109    /// protocol.
110    ///
111    /// # Errors
112    ///
113    /// This returns an error if any of the URL parsing fails, or if, for
114    /// example, the location is a path and the path isn't valid UTF-8.
115    /// (Because URLs must be valid UTF-8.)
116    pub fn to_url(&self) -> Result<DisplaySafeUrl, ToUrlError> {
117        match self {
118            Self::RelativeUrl(base, path) => {
119                let base_url =
120                    DisplaySafeUrl::parse(base).map_err(|err| ToUrlError::InvalidBase {
121                        base: base.to_string(),
122                        err,
123                    })?;
124                let joined = base_url.join(path).map_err(|err| ToUrlError::InvalidJoin {
125                    base: base.to_string(),
126                    path: path.to_string(),
127                    err,
128                })?;
129                Ok(joined)
130            }
131            Self::AbsoluteUrl(absolute) => absolute.to_url(),
132        }
133    }
134}
135
136impl Display for FileLocation {
137    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
138        match self {
139            Self::RelativeUrl(_base, url) => Display::fmt(&url, f),
140            Self::AbsoluteUrl(url) => Display::fmt(&url.0, f),
141        }
142    }
143}
144
145/// A [`Url`] represented as a `String`.
146///
147/// This type is not guaranteed to be a valid URL, and may error on conversion.
148#[derive(
149    Debug,
150    Clone,
151    PartialEq,
152    Eq,
153    PartialOrd,
154    Ord,
155    Hash,
156    Serialize,
157    Deserialize,
158    rkyv::Archive,
159    rkyv::Deserialize,
160    rkyv::Serialize,
161)]
162#[serde(transparent)]
163#[rkyv(derive(Debug))]
164pub struct UrlString(SmallString);
165
166impl UrlString {
167    /// Create a new [`UrlString`] from a [`String`].
168    fn new(url: SmallString) -> Self {
169        Self(url)
170    }
171
172    /// Converts a [`UrlString`] to a [`DisplaySafeUrl`].
173    pub fn to_url(&self) -> Result<DisplaySafeUrl, ToUrlError> {
174        DisplaySafeUrl::from_str(&self.0).map_err(|err| ToUrlError::InvalidAbsolute {
175            absolute: self.0.to_string(),
176            err,
177        })
178    }
179
180    /// Return the [`UrlString`] with any query parameters and fragments removed.
181    pub fn base_str(&self) -> &str {
182        self.as_ref()
183            .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#[cfg(test)]
260mod tests {
261    use std::assert_matches;
262
263    use super::*;
264
265    #[test]
266    fn raw_filename() {
267        let base = SmallString::from("https://example.com/simple/");
268
269        let location = FileLocation::new(
270            SmallString::from("files/example%20pkg.whl?download=1#fragment"),
271            &base,
272        );
273        assert_eq!(location.raw_filename(), "example%20pkg.whl");
274
275        let location = FileLocation::new(
276            SmallString::from("https://files.example.com/example.whl#sha256=digest"),
277            &base,
278        );
279        assert_eq!(location.raw_filename(), "example.whl");
280    }
281
282    #[test]
283    fn base_str() {
284        let url = UrlString("https://example.com/path?query#fragment".into());
285        assert_eq!(url.base_str(), "https://example.com/path");
286
287        let url = UrlString("https://example.com/path#fragment".into());
288        assert_eq!(url.base_str(), "https://example.com/path");
289
290        let url = UrlString("https://example.com/path#fragment?query".into());
291        assert_eq!(url.base_str(), "https://example.com/path");
292
293        let url = UrlString("https://example.com/path#fragment/part?query".into());
294        assert_eq!(url.base_str(), "https://example.com/path");
295
296        let url = UrlString("https://example.com/path".into());
297        assert_eq!(url.base_str(), "https://example.com/path");
298    }
299
300    #[test]
301    fn without_fragment() {
302        // Borrows a URL without a fragment
303        let url = UrlString("https://example.com/path".into());
304        assert_eq!(&*url.without_fragment(), &url);
305        assert_matches!(url.without_fragment(), Cow::Borrowed(_));
306
307        // Removes the fragment if present on the URL
308        let url = UrlString("https://example.com/path?query#fragment".into());
309        assert_eq!(
310            &*url.without_fragment(),
311            &UrlString("https://example.com/path?query".into())
312        );
313        assert_matches!(url.without_fragment(), Cow::Owned(_));
314    }
315}