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 [`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 parsed as-is.
108    ///
109    /// # Errors
110    ///
111    /// This returns an error if parsing the base or absolute URL fails, or if the relative path
112    /// cannot be joined to the base URL.
113    pub fn to_url(&self) -> Result<DisplaySafeUrl, ToUrlError> {
114        match self {
115            Self::RelativeUrl(base, path) => {
116                let base_url =
117                    DisplaySafeUrl::parse(base).map_err(|err| ToUrlError::InvalidBase {
118                        base: base.to_string(),
119                        err,
120                    })?;
121                let joined = base_url.join(path).map_err(|err| ToUrlError::InvalidJoin {
122                    base: base.to_string(),
123                    path: path.to_string(),
124                    err,
125                })?;
126                Ok(joined)
127            }
128            Self::AbsoluteUrl(absolute) => absolute.to_url(),
129        }
130    }
131}
132
133impl Display for FileLocation {
134    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
135        match self {
136            Self::RelativeUrl(_base, url) => Display::fmt(&url, f),
137            Self::AbsoluteUrl(url) => Display::fmt(&url.0, f),
138        }
139    }
140}
141
142/// A URL represented as a [`SmallString`].
143///
144/// This type is not guaranteed to be a valid URL, and may error on conversion.
145#[derive(
146    Debug,
147    Clone,
148    PartialEq,
149    Eq,
150    PartialOrd,
151    Ord,
152    Hash,
153    Serialize,
154    Deserialize,
155    rkyv::Archive,
156    rkyv::Deserialize,
157    rkyv::Serialize,
158)]
159#[serde(transparent)]
160#[rkyv(derive(Debug))]
161pub struct UrlString(SmallString);
162
163impl UrlString {
164    /// Create a new [`UrlString`] from a [`SmallString`].
165    fn new(url: SmallString) -> Self {
166        Self(url)
167    }
168
169    /// Converts a [`UrlString`] to a [`DisplaySafeUrl`].
170    pub fn to_url(&self) -> Result<DisplaySafeUrl, ToUrlError> {
171        DisplaySafeUrl::from_str(&self.0).map_err(|err| ToUrlError::InvalidAbsolute {
172            absolute: self.0.to_string(),
173            err,
174        })
175    }
176
177    /// Return the [`UrlString`] with any query parameters and fragments removed.
178    pub fn base_str(&self) -> &str {
179        self.as_ref()
180            .split_once(['?', '#'])
181            .map(|(path, _)| path)
182            .unwrap_or(self.as_ref())
183    }
184
185    /// Return the [`UrlString`] (as a [`Cow`]) with any fragments removed.
186    #[must_use]
187    pub fn without_fragment(&self) -> Cow<'_, Self> {
188        self.as_ref()
189            .split_once('#')
190            .map(|(path, _)| Cow::Owned(Self(SmallString::from(path))))
191            .unwrap_or(Cow::Borrowed(self))
192    }
193}
194
195impl AsRef<str> for UrlString {
196    fn as_ref(&self) -> &str {
197        &self.0
198    }
199}
200
201impl From<DisplaySafeUrl> for UrlString {
202    fn from(value: DisplaySafeUrl) -> Self {
203        Self(value.as_str().into())
204    }
205}
206
207impl From<&DisplaySafeUrl> for UrlString {
208    fn from(value: &DisplaySafeUrl) -> Self {
209        Self(value.as_str().into())
210    }
211}
212
213impl Display for UrlString {
214    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
215        fmt::Display::fmt(&self.0, f)
216    }
217}
218
219/// An error that occurs when a [`FileLocation`] is not a valid URL.
220#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
221pub enum ToUrlError {
222    /// An error that occurs when the base URL in [`FileLocation::RelativeUrl`]
223    /// could not be parsed as a valid URL.
224    #[error("Could not parse base URL `{base}` as a valid URL")]
225    InvalidBase {
226        /// The base URL that could not be parsed as a valid URL.
227        base: String,
228        /// The underlying URL parse error.
229        #[source]
230        err: DisplaySafeUrlError,
231    },
232    /// An error that occurs when the base URL could not be joined with
233    /// the relative path in a [`FileLocation::RelativeUrl`].
234    #[error("Could not join base URL `{base}` to relative path `{path}`")]
235    InvalidJoin {
236        /// The base URL that could not be parsed as a valid URL.
237        base: String,
238        /// The relative path segment.
239        path: String,
240        /// The underlying URL parse error.
241        #[source]
242        err: DisplaySafeUrlError,
243    },
244    /// An error that occurs when the absolute URL in [`FileLocation::AbsoluteUrl`]
245    /// could not be parsed as a valid URL.
246    #[error("Could not parse absolute URL `{absolute}` as a valid URL")]
247    InvalidAbsolute {
248        /// The absolute URL that could not be parsed as a valid URL.
249        absolute: String,
250        /// The underlying URL parse error.
251        #[source]
252        err: DisplaySafeUrlError,
253    },
254}
255
256#[cfg(test)]
257mod tests {
258    use std::assert_matches;
259
260    use super::*;
261
262    #[test]
263    fn raw_filename() {
264        let base = SmallString::from("https://example.com/simple/");
265
266        let location = FileLocation::new(
267            SmallString::from("files/example%20pkg.whl?download=1#fragment"),
268            &base,
269        );
270        assert_eq!(location.raw_filename(), "example%20pkg.whl");
271
272        let location = FileLocation::new(
273            SmallString::from("https://files.example.com/example.whl#sha256=digest"),
274            &base,
275        );
276        assert_eq!(location.raw_filename(), "example.whl");
277    }
278
279    #[test]
280    fn base_str() {
281        let url = UrlString("https://example.com/path?query#fragment".into());
282        assert_eq!(url.base_str(), "https://example.com/path");
283
284        let url = UrlString("https://example.com/path#fragment".into());
285        assert_eq!(url.base_str(), "https://example.com/path");
286
287        let url = UrlString("https://example.com/path#fragment?query".into());
288        assert_eq!(url.base_str(), "https://example.com/path");
289
290        let url = UrlString("https://example.com/path#fragment/part?query".into());
291        assert_eq!(url.base_str(), "https://example.com/path");
292
293        let url = UrlString("https://example.com/path".into());
294        assert_eq!(url.base_str(), "https://example.com/path");
295    }
296
297    #[test]
298    fn without_fragment() {
299        // Borrows a URL without a fragment
300        let url = UrlString("https://example.com/path".into());
301        assert_eq!(&*url.without_fragment(), &url);
302        assert_matches!(url.without_fragment(), Cow::Borrowed(_));
303
304        // Removes the fragment if present on the URL
305        let url = UrlString("https://example.com/path?query#fragment".into());
306        assert_eq!(
307            &*url.without_fragment(),
308            &UrlString("https://example.com/path?query".into())
309        );
310        assert_matches!(url.without_fragment(), Cow::Owned(_));
311    }
312}