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