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