Skip to main content

uv_distribution_types/
id.rs

1use std::fmt::{Display, Formatter};
2use std::path::{Path, PathBuf};
3
4use uv_cache_key::{CanonicalUrl, RepositoryUrl};
5use uv_git_types::GitUrl;
6
7use uv_normalize::PackageName;
8use uv_pep440::Version;
9use uv_pypi_types::{HashDigest, ParsedUrl};
10use uv_redacted::DisplaySafeUrl;
11
12/// A unique identifier for a package. A package can either be identified by a name (e.g., `black`)
13/// or a URL (e.g., `git+https://github.com/psf/black`).
14#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
15pub enum PackageId {
16    /// The identifier consists of a package name.
17    Name(PackageName),
18    /// The identifier consists of a URL.
19    Url(CanonicalUrl),
20}
21
22impl PackageId {
23    /// Create a new [`PackageId`] from a package name and version.
24    pub(crate) fn from_registry(name: PackageName) -> Self {
25        Self::Name(name)
26    }
27
28    /// Create a new [`PackageId`] from a URL.
29    pub(crate) fn from_url(url: DisplaySafeUrl) -> Self {
30        Self::Url(CanonicalUrl::new(url))
31    }
32}
33
34impl Display for PackageId {
35    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
36        match self {
37            Self::Name(name) => write!(f, "{name}"),
38            Self::Url(url) => write!(f, "{url}"),
39        }
40    }
41}
42
43/// A unique identifier for a package at a specific version (e.g., `black==23.10.0`).
44///
45/// URL-based variants use kind-specific identity semantics. Archive URLs ignore hash fragments
46/// while preserving semantic `subdirectory` information. Git URLs preserve semantic
47/// `subdirectory` information while ignoring unrelated fragments. Local file URLs are keyed by
48/// their resolved path and kind.
49#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
50pub enum VersionId {
51    /// The identifier consists of a package name and version.
52    NameVersion(PackageName, Version),
53    /// The identifier consists of an archive URL identified by its location and optional source
54    /// subdirectory.
55    ArchiveUrl {
56        location: CanonicalUrl,
57        subdirectory: Option<PathBuf>,
58    },
59    /// The identifier consists of a Git repository URL, its reference, and optional source
60    /// subdirectory.
61    Git {
62        url: GitUrl,
63        subdirectory: Option<PathBuf>,
64    },
65    /// The identifier consists of a local file path.
66    Path(PathBuf),
67    /// The identifier consists of a local directory path.
68    Directory(PathBuf),
69    /// The identifier consists of a URL whose source kind could not be determined.
70    Unknown(DisplaySafeUrl),
71}
72
73impl VersionId {
74    /// Create a new [`VersionId`] from a package name and version.
75    pub fn from_registry(name: PackageName, version: Version) -> Self {
76        Self::NameVersion(name, version)
77    }
78
79    /// Create a new [`VersionId`] from a parsed URL.
80    pub fn from_parsed_url(url: ParsedUrl) -> Self {
81        match url {
82            ParsedUrl::Path(path) => Self::Path(path.install_path.into_path_buf()),
83            ParsedUrl::Directory(directory) => {
84                Self::Directory(directory.install_path.into_path_buf())
85            }
86            ParsedUrl::GitDirectory(git) => Self::Git {
87                url: git.url,
88                subdirectory: git.subdirectory.map(Path::into_path_buf),
89            },
90            ParsedUrl::GitPath(git) => Self::Git {
91                url: git.url,
92                subdirectory: Some(git.install_path),
93            },
94            ParsedUrl::Archive(archive) => {
95                Self::from_archive(archive.url, archive.subdirectory.map(Path::into_path_buf))
96            }
97        }
98    }
99
100    /// Create a new [`VersionId`] from a URL.
101    pub fn from_url(url: &DisplaySafeUrl) -> Self {
102        match ParsedUrl::try_from(url.clone()) {
103            Ok(parsed) => Self::from_parsed_url(parsed),
104            Err(_) => Self::Unknown(url.clone()),
105        }
106    }
107
108    /// Create a new [`VersionId`] from an archive URL.
109    pub fn from_archive(location: DisplaySafeUrl, subdirectory: Option<PathBuf>) -> Self {
110        Self::ArchiveUrl {
111            location: CanonicalUrl::new(location),
112            subdirectory,
113        }
114    }
115
116    /// Create a new [`VersionId`] from a Git URL.
117    pub fn from_git(git: &GitUrl, subdirectory: Option<&Path>) -> Self {
118        Self::Git {
119            url: git.clone(),
120            subdirectory: subdirectory.map(Path::to_path_buf),
121        }
122    }
123
124    /// Create a new [`VersionId`] from a local file path.
125    pub fn from_path(path: &Path) -> Self {
126        Self::Path(path.to_path_buf())
127    }
128
129    /// Create a new [`VersionId`] from a local directory path.
130    pub fn from_directory(path: &Path) -> Self {
131        Self::Directory(path.to_path_buf())
132    }
133}
134
135impl Display for VersionId {
136    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
137        match self {
138            Self::NameVersion(name, version) => write!(f, "{name}-{version}"),
139            Self::ArchiveUrl {
140                location,
141                subdirectory,
142            } => {
143                let mut location = DisplaySafeUrl::from(location.clone());
144                if let Some(subdirectory) = subdirectory {
145                    location
146                        .set_fragment(Some(&format!("subdirectory={}", subdirectory.display())));
147                }
148                write!(f, "{location}")
149            }
150            Self::Git { url, subdirectory } => {
151                let mut git_url = DisplaySafeUrl::parse(&format!("git+{}", url.url()))
152                    .expect("Git URLs should be display-safe");
153                if let Some(precise) = url.precise() {
154                    let path = format!("{}@{}", git_url.path(), precise);
155                    git_url.set_path(&path);
156                } else if let Some(reference) = url.reference().as_str() {
157                    let path = format!("{}@{}", git_url.path(), reference);
158                    git_url.set_path(&path);
159                }
160
161                let mut fragments = Vec::new();
162                if let Some(subdirectory) = subdirectory {
163                    fragments.push(format!("subdirectory={}", subdirectory.display()));
164                }
165                if url.lfs().enabled() {
166                    fragments.push("lfs=true".to_string());
167                }
168                if !fragments.is_empty() {
169                    git_url.set_fragment(Some(&fragments.join("&")));
170                }
171
172                write!(f, "{git_url}")
173            }
174            Self::Path(path) | Self::Directory(path) => {
175                if let Ok(url) = DisplaySafeUrl::from_file_path(path) {
176                    write!(f, "{url}")
177                } else {
178                    write!(f, "{}", path.display())
179                }
180            }
181            Self::Unknown(url) => write!(f, "{url}"),
182        }
183    }
184}
185
186/// A unique resource identifier for the distribution, like a SHA-256 hash of the distribution's
187/// contents.
188///
189/// A distribution is a specific archive of a package at a specific version. For a given package
190/// version, there may be multiple distributions, e.g., source distribution, along with
191/// multiple binary distributions (wheels) for different platforms. As a concrete example,
192/// `black-23.10.0-py3-none-any.whl` would represent a (binary) distribution of the `black` package
193/// at version `23.10.0`.
194///
195/// The distribution ID is used to uniquely identify a distribution. Ideally, the distribution
196/// ID should be a hash of the distribution's contents, though in practice, it's only required
197/// that the ID is unique within a single invocation of the resolver (and so, e.g., a hash of
198/// the URL would also be sufficient).
199#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
200pub enum DistributionId {
201    Url(CanonicalUrl),
202    PathBuf(PathBuf),
203    Digest(HashDigest),
204    AbsoluteUrl(String),
205    RelativeUrl(String, String),
206}
207
208/// A unique identifier for a resource, like a URL or a Git repository.
209#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
210pub enum ResourceId {
211    Url(RepositoryUrl),
212    PathBuf(PathBuf),
213    Digest(HashDigest),
214    AbsoluteUrl(String),
215    RelativeUrl(String, String),
216}
217
218impl From<&Self> for VersionId {
219    /// Required for `WaitMap::wait`.
220    fn from(value: &Self) -> Self {
221        value.clone()
222    }
223}
224
225impl From<&Self> for DistributionId {
226    /// Required for `WaitMap::wait`.
227    fn from(value: &Self) -> Self {
228        value.clone()
229    }
230}
231
232impl From<&Self> for ResourceId {
233    /// Required for `WaitMap::wait`.
234    fn from(value: &Self) -> Self {
235        value.clone()
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use std::time::{SystemTime, UNIX_EPOCH};
242
243    use fs_err as fs;
244
245    use super::VersionId;
246    use uv_redacted::DisplaySafeUrl;
247
248    #[test]
249    fn version_id_ignores_hash_fragments() {
250        let first = DisplaySafeUrl::parse(
251            "https://example.com/pkg-0.1.0.whl#sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
252        )
253        .unwrap();
254        let second = DisplaySafeUrl::parse(
255            "https://example.com/pkg-0.1.0.whl#sha512=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
256        )
257        .unwrap();
258
259        assert_eq!(VersionId::from_url(&first), VersionId::from_url(&second));
260    }
261
262    #[test]
263    fn version_id_preserves_non_hash_fragments() {
264        let first =
265            DisplaySafeUrl::parse("https://example.com/pkg-0.1.0.tar.gz#subdirectory=foo").unwrap();
266        let second =
267            DisplaySafeUrl::parse("https://example.com/pkg-0.1.0.tar.gz#subdirectory=bar").unwrap();
268
269        assert_ne!(VersionId::from_url(&first), VersionId::from_url(&second));
270    }
271
272    #[test]
273    fn version_id_ignores_hash_fragments_with_subdirectory() {
274        let first = DisplaySafeUrl::parse(
275            "https://example.com/pkg-0.1.0.tar.gz#subdirectory=foo&sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
276        )
277        .unwrap();
278        let second = DisplaySafeUrl::parse(
279            "https://example.com/pkg-0.1.0.tar.gz#sha512=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&subdirectory=foo",
280        )
281        .unwrap();
282
283        assert_eq!(VersionId::from_url(&first), VersionId::from_url(&second));
284    }
285
286    #[test]
287    fn version_id_preserves_non_archive_fragments() {
288        let first =
289            DisplaySafeUrl::parse("git+https://example.com/pkg.git#subdirectory=foo").unwrap();
290        let second =
291            DisplaySafeUrl::parse("git+https://example.com/pkg.git#subdirectory=bar").unwrap();
292
293        assert_ne!(VersionId::from_url(&first), VersionId::from_url(&second));
294    }
295
296    #[test]
297    fn version_id_ignores_irrelevant_git_fragments() {
298        let first =
299            DisplaySafeUrl::parse("git+https://example.com/pkg.git@main#egg=pkg&subdirectory=foo")
300                .unwrap();
301        let second =
302            DisplaySafeUrl::parse("git+https://example.com/pkg.git@main#subdirectory=foo").unwrap();
303
304        assert_eq!(VersionId::from_url(&first), VersionId::from_url(&second));
305    }
306
307    #[test]
308    fn version_id_uses_file_kinds() {
309        let nonce = SystemTime::now()
310            .duration_since(UNIX_EPOCH)
311            .unwrap()
312            .as_nanos();
313        let root = std::env::temp_dir().join(format!("uv-version-id-{nonce}"));
314        let file = root.join("pkg-0.1.0.whl");
315        let directory = root.join("pkg");
316
317        fs::create_dir_all(&directory).unwrap();
318        fs::write(&file, b"wheel").unwrap();
319
320        let file_url = DisplaySafeUrl::from_file_path(&file).unwrap();
321        let directory_url = DisplaySafeUrl::from_file_path(&directory).unwrap();
322
323        assert!(matches!(VersionId::from_url(&file_url), VersionId::Path(_)));
324        assert!(matches!(
325            VersionId::from_url(&directory_url),
326            VersionId::Directory(_)
327        ));
328
329        fs::remove_file(file).unwrap();
330        fs::remove_dir_all(root).unwrap();
331    }
332
333    #[test]
334    fn version_id_uses_unknown_for_invalid_git_like_urls() {
335        let url =
336            DisplaySafeUrl::parse("git+ftp://example.com/pkg.git@main#subdirectory=foo").unwrap();
337
338        assert!(matches!(VersionId::from_url(&url), VersionId::Unknown(_)));
339    }
340}