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_fs::normalize_path;
6use uv_git_types::GitUrl;
7
8use uv_normalize::PackageName;
9use uv_pep440::Version;
10use uv_pypi_types::{HashDigest, ParsedUrl};
11use uv_redacted::DisplaySafeUrl;
12
13/// A unique identifier for a package. A package can either be identified by a name (e.g., `black`)
14/// or a URL (e.g., `git+https://github.com/psf/black`).
15#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub enum PackageId {
17    /// The identifier consists of a package name.
18    Name(PackageName),
19    /// The identifier consists of a URL.
20    Url(CanonicalUrl),
21}
22
23impl PackageId {
24    /// Create a new [`PackageId`] from a package name and version.
25    pub(crate) fn from_registry(name: PackageName) -> Self {
26        Self::Name(name)
27    }
28
29    /// Create a new [`PackageId`] from a URL.
30    pub(crate) fn from_url(url: DisplaySafeUrl) -> Self {
31        Self::Url(CanonicalUrl::new(url))
32    }
33}
34
35impl Display for PackageId {
36    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::Name(name) => write!(f, "{name}"),
39            Self::Url(url) => write!(f, "{url}"),
40        }
41    }
42}
43
44/// A unique identifier for a package at a specific version (e.g., `black==23.10.0`).
45///
46/// URL-based variants use kind-specific identity semantics. Archive URLs ignore hash fragments
47/// while preserving semantic `subdirectory` information. Git URLs preserve semantic
48/// `subdirectory` information while ignoring unrelated fragments. Local file URLs are keyed by
49/// their resolved path and kind.
50#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
51pub enum VersionId {
52    /// The identifier consists of a package name and version.
53    NameVersion(PackageName, Version),
54    /// The identifier consists of an archive URL identified by its location and optional source
55    /// subdirectory.
56    ArchiveUrl {
57        location: CanonicalUrl,
58        subdirectory: Option<PathBuf>,
59    },
60    /// The identifier consists of a Git repository URL, its reference, and optional source
61    /// subdirectory.
62    Git {
63        url: GitUrl,
64        subdirectory: Option<PathBuf>,
65    },
66    /// The identifier consists of a local file path.
67    Path(PathBuf),
68    /// The identifier consists of a local directory path.
69    Directory(PathBuf),
70    /// The identifier consists of a URL whose source kind could not be determined.
71    Unknown(DisplaySafeUrl),
72}
73
74impl VersionId {
75    /// Create a new [`VersionId`] from a package name and version.
76    pub fn from_registry(name: PackageName, version: Version) -> Self {
77        Self::NameVersion(name, version)
78    }
79
80    /// Create a new [`VersionId`] from a parsed URL.
81    pub fn from_parsed_url(url: ParsedUrl) -> Self {
82        match url {
83            ParsedUrl::Path(path) => Self::Path(path.install_path.into_path_buf()),
84            ParsedUrl::Directory(directory) => {
85                Self::Directory(directory.install_path.into_path_buf())
86            }
87            ParsedUrl::GitDirectory(git) => Self::Git {
88                url: git.url,
89                subdirectory: git.subdirectory.map(Path::into_path_buf),
90            },
91            ParsedUrl::GitPath(git) => Self::Git {
92                url: git.url,
93                subdirectory: Some(git.install_path),
94            },
95            ParsedUrl::Archive(archive) => {
96                Self::from_archive(archive.url, archive.subdirectory.map(Path::into_path_buf))
97            }
98        }
99    }
100
101    /// Create a new [`VersionId`] from a URL.
102    pub fn from_url(url: &DisplaySafeUrl) -> Self {
103        match ParsedUrl::try_from(url.clone()) {
104            Ok(parsed) => Self::from_parsed_url(parsed),
105            Err(_) => Self::Unknown(url.clone()),
106        }
107    }
108
109    /// Create a new [`VersionId`] from an archive URL.
110    pub fn from_archive(location: DisplaySafeUrl, subdirectory: Option<PathBuf>) -> Self {
111        // Use the same lexical normalization as the resolver's source comparison. Equivalent
112        // subdirectories must not lose their trusted hashes during lowering or lockfile reads.
113        let subdirectory = subdirectory
114            .map(|path| normalize_path(path).into_owned())
115            .filter(|path| !path.as_os_str().is_empty());
116        Self::ArchiveUrl {
117            location: CanonicalUrl::new(location),
118            subdirectory,
119        }
120    }
121
122    /// Create a new [`VersionId`] from a Git URL.
123    pub fn from_git(git: &GitUrl, subdirectory: Option<&Path>) -> Self {
124        Self::Git {
125            url: git.clone(),
126            subdirectory: subdirectory.map(Path::to_path_buf),
127        }
128    }
129
130    /// Create a new [`VersionId`] from a local file path.
131    pub fn from_path(path: &Path) -> Self {
132        Self::Path(path.to_path_buf())
133    }
134
135    /// Create a new [`VersionId`] from a local directory path.
136    pub fn from_directory(path: &Path) -> Self {
137        Self::Directory(path.to_path_buf())
138    }
139}
140
141impl Display for VersionId {
142    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
143        match self {
144            Self::NameVersion(name, version) => write!(f, "{name}-{version}"),
145            Self::ArchiveUrl {
146                location,
147                subdirectory,
148            } => {
149                let mut location = DisplaySafeUrl::from(location.clone());
150                if let Some(subdirectory) = subdirectory {
151                    location
152                        .set_fragment(Some(&format!("subdirectory={}", subdirectory.display())));
153                }
154                write!(f, "{location}")
155            }
156            Self::Git { url, subdirectory } => {
157                let mut git_url = DisplaySafeUrl::parse(&format!("git+{}", url.url()))
158                    .expect("Git URLs should be display-safe");
159                if let Some(precise) = url.precise() {
160                    let path = format!("{}@{}", git_url.path(), precise);
161                    git_url.set_path(&path);
162                } else if let Some(reference) = url.reference().as_str() {
163                    let path = format!("{}@{}", git_url.path(), reference);
164                    git_url.set_path(&path);
165                }
166
167                let mut fragments = Vec::new();
168                if let Some(subdirectory) = subdirectory {
169                    fragments.push(format!("subdirectory={}", subdirectory.display()));
170                }
171                if url.lfs().enabled() {
172                    fragments.push("lfs=true".to_string());
173                }
174                if !fragments.is_empty() {
175                    git_url.set_fragment(Some(&fragments.join("&")));
176                }
177
178                write!(f, "{git_url}")
179            }
180            Self::Path(path) | Self::Directory(path) => {
181                if let Ok(url) = DisplaySafeUrl::from_file_path(path) {
182                    write!(f, "{url}")
183                } else {
184                    write!(f, "{}", path.display())
185                }
186            }
187            Self::Unknown(url) => write!(f, "{url}"),
188        }
189    }
190}
191
192/// A unique resource identifier for the distribution, like a SHA-256 hash of the distribution's
193/// contents.
194///
195/// A distribution is a specific archive of a package at a specific version. For a given package
196/// version, there may be multiple distributions, e.g., source distribution, along with
197/// multiple binary distributions (wheels) for different platforms. As a concrete example,
198/// `black-23.10.0-py3-none-any.whl` would represent a (binary) distribution of the `black` package
199/// at version `23.10.0`.
200///
201/// The distribution ID is used to uniquely identify a distribution. Ideally, the distribution
202/// ID should be a hash of the distribution's contents, though in practice, it's only required
203/// that the ID is unique within a single invocation of the resolver (and so, e.g., a hash of
204/// the URL would also be sufficient).
205#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
206pub enum DistributionId {
207    Url(CanonicalUrl),
208    PathBuf(PathBuf),
209    Digest(HashDigest),
210    AbsoluteUrl(String),
211    RelativeUrl(String, String),
212}
213
214/// A unique identifier for a resource, like a URL or a Git repository.
215#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
216pub enum ResourceId {
217    Url(RepositoryUrl),
218    PathBuf(PathBuf),
219    Digest(HashDigest),
220    AbsoluteUrl(String),
221    RelativeUrl(String, String),
222}
223
224impl From<&Self> for VersionId {
225    /// Required for `WaitMap::wait`.
226    fn from(value: &Self) -> Self {
227        value.clone()
228    }
229}
230
231impl From<&Self> for DistributionId {
232    /// Required for `WaitMap::wait`.
233    fn from(value: &Self) -> Self {
234        value.clone()
235    }
236}
237
238impl From<&Self> for ResourceId {
239    /// Required for `WaitMap::wait`.
240    fn from(value: &Self) -> Self {
241        value.clone()
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use std::assert_matches;
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!(VersionId::from_url(&directory_url), VersionId::Directory(_));
332
333        fs::remove_file(file).unwrap();
334        fs::remove_dir_all(root).unwrap();
335    }
336
337    #[test]
338    fn version_id_uses_unknown_for_invalid_git_like_urls() {
339        let url =
340            DisplaySafeUrl::parse("git+ftp://example.com/pkg.git@main#subdirectory=foo").unwrap();
341
342        assert_matches!(VersionId::from_url(&url), VersionId::Unknown(_));
343    }
344}