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