Use structured source data for version IDs (#18840)

## Summary

Right now, two direct URLs with different hash fragments are considered
different versions based on `VersionId`. This PR changes the internal
representation to match our parsed URL structure. E.g., these should be
considered the same "version" if specified as direct URLs:

```text
https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha256=cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f

https://files.pythonhosted.org/packages/36/55/ad4de788d84a630656ece71059665e01ca793c04294c463fd84132f40fe6/anyio-4.0.0-py3-none-any.whl#sha512=f30761c1e8725b49c498273b90dba4b05c0fd157811994c806183062cb6647e773364ce45f0e1ff0b10e32fe6d0232ea5ad39476ccf37109d6b49603a09c11c2
```
This commit is contained in:
Charlie Marsh
2026-04-07 12:07:29 -04:00
committed by GitHub
parent 5108919f03
commit c5977cc4ae
5 changed files with 317 additions and 9 deletions
+12 -1
View File
@@ -7,7 +7,7 @@ use uv_pypi_types::{HashDigest, HashDigests, VerbatimParsedUrl};
use crate::{
BuildInfo, BuiltDist, Dist, DistributionMetadata, Hashed, InstalledMetadata, InstalledVersion,
Name, ParsedUrl, SourceDist, VersionOrUrlRef,
Name, ParsedUrl, SourceDist, VersionId, VersionOrUrlRef,
};
/// A built distribution (wheel) that exists in the local cache.
@@ -211,6 +211,10 @@ impl DistributionMetadata for CachedDirectUrlDist {
fn version_or_url(&self) -> VersionOrUrlRef<'_> {
VersionOrUrlRef::Url(&self.url.verbatim)
}
fn version_id(&self) -> VersionId {
VersionId::from_parsed_url(&self.url.parsed_url)
}
}
impl DistributionMetadata for CachedDist {
@@ -220,6 +224,13 @@ impl DistributionMetadata for CachedDist {
Self::Url(dist) => dist.version_or_url(),
}
}
fn version_id(&self) -> VersionId {
match self {
Self::Registry(dist) => dist.version_id(),
Self::Url(dist) => dist.version_id(),
}
}
}
impl InstalledMetadata for CachedRegistryDist {
+240 -6
View File
@@ -1,11 +1,14 @@
use std::fmt::{Display, Formatter};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use uv_cache_key::{CanonicalUrl, RepositoryUrl};
use uv_git_types::GitUrl;
use uv_normalize::PackageName;
use uv_pep440::Version;
use uv_pypi_types::HashDigest;
use uv_pypi_types::{
HashDigest, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl,
};
use uv_redacted::DisplaySafeUrl;
/// A unique identifier for a package. A package can either be identified by a name (e.g., `black`)
@@ -40,12 +43,33 @@ impl Display for PackageId {
}
/// A unique identifier for a package at a specific version (e.g., `black==23.10.0`).
///
/// URL-based variants use kind-specific identity semantics. Archive URLs ignore hash fragments
/// while preserving semantic `subdirectory` information. Git URLs preserve semantic
/// `subdirectory` information while ignoring unrelated fragments. Local file URLs are keyed by
/// their resolved path and kind.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum VersionId {
/// The identifier consists of a package name and version.
NameVersion(PackageName, Version),
/// The identifier consists of a URL.
Url(CanonicalUrl),
/// The identifier consists of an archive URL identified by its location and optional source
/// subdirectory.
ArchiveUrl {
location: CanonicalUrl,
subdirectory: Option<PathBuf>,
},
/// The identifier consists of a Git repository URL, its reference, and optional source
/// subdirectory.
Git {
url: GitUrl,
subdirectory: Option<PathBuf>,
},
/// The identifier consists of a local file path.
Path(PathBuf),
/// The identifier consists of a local directory path.
Directory(PathBuf),
/// The identifier consists of a URL whose source kind could not be determined.
Unknown(DisplaySafeUrl),
}
impl VersionId {
@@ -54,9 +78,74 @@ impl VersionId {
Self::NameVersion(name, version)
}
/// Create a new [`VersionId`] from a parsed URL.
pub fn from_parsed_url(url: &ParsedUrl) -> Self {
match url {
ParsedUrl::Path(path) => Self::from_path_url(path),
ParsedUrl::Directory(directory) => Self::from_directory_url(directory),
ParsedUrl::Git(git) => Self::from_git_url(git),
ParsedUrl::Archive(archive) => Self::from_archive_url(archive),
}
}
/// Create a new [`VersionId`] from a URL.
pub fn from_url(url: &DisplaySafeUrl) -> Self {
Self::Url(CanonicalUrl::new(url))
match ParsedUrl::try_from(url.clone()) {
Ok(parsed) => Self::from_parsed_url(&parsed),
Err(_) => Self::Unknown(url.clone()),
}
}
/// Create a new [`VersionId`] from an archive URL.
pub fn from_archive(location: &DisplaySafeUrl, subdirectory: Option<&Path>) -> Self {
Self::ArchiveUrl {
location: CanonicalUrl::new(location),
subdirectory: subdirectory.map(Path::to_path_buf),
}
}
/// Create a new [`VersionId`] from a Git URL.
pub fn from_git(git: &GitUrl, subdirectory: Option<&Path>) -> Self {
// TODO(charlie): Canonicalize repository URLs in `GitUrl` itself so `VersionId` does not
// need to rebuild the value here.
let git = GitUrl::from_fields(
DisplaySafeUrl::from(CanonicalUrl::new(git.repository())),
git.reference().clone(),
git.precise(),
git.lfs(),
)
.expect("canonical Git URLs should preserve supported schemes");
Self::Git {
url: git,
subdirectory: subdirectory.map(Path::to_path_buf),
}
}
/// Create a new [`VersionId`] from a local file path.
pub fn from_path(path: &Path) -> Self {
Self::Path(path.to_path_buf())
}
/// Create a new [`VersionId`] from a local directory path.
pub fn from_directory(path: &Path) -> Self {
Self::Directory(path.to_path_buf())
}
fn from_archive_url(archive: &ParsedArchiveUrl) -> Self {
Self::from_archive(&archive.url, archive.subdirectory.as_deref())
}
fn from_path_url(path: &ParsedPathUrl) -> Self {
Self::from_path(path.install_path.as_ref())
}
fn from_directory_url(directory: &ParsedDirectoryUrl) -> Self {
Self::from_directory(directory.install_path.as_ref())
}
fn from_git_url(git: &ParsedGitUrl) -> Self {
Self::from_git(&git.url, git.subdirectory.as_deref())
}
}
@@ -64,7 +153,49 @@ impl Display for VersionId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::NameVersion(name, version) => write!(f, "{name}-{version}"),
Self::Url(url) => write!(f, "{url}"),
Self::ArchiveUrl {
location,
subdirectory,
} => {
let mut location = DisplaySafeUrl::from(location.clone());
if let Some(subdirectory) = subdirectory {
location
.set_fragment(Some(&format!("subdirectory={}", subdirectory.display())));
}
write!(f, "{location}")
}
Self::Git { url, subdirectory } => {
let mut git_url = DisplaySafeUrl::parse(&format!("git+{}", url.repository()))
.expect("canonical Git URLs should be display-safe");
if let Some(precise) = url.precise() {
let path = format!("{}@{}", git_url.path(), precise);
git_url.set_path(&path);
} else if let Some(reference) = url.reference().as_str() {
let path = format!("{}@{}", git_url.path(), reference);
git_url.set_path(&path);
}
let mut fragments = Vec::new();
if let Some(subdirectory) = subdirectory {
fragments.push(format!("subdirectory={}", subdirectory.display()));
}
if url.lfs().enabled() {
fragments.push("lfs=true".to_string());
}
if !fragments.is_empty() {
git_url.set_fragment(Some(&fragments.join("&")));
}
write!(f, "{git_url}")
}
Self::Path(path) | Self::Directory(path) => {
if let Ok(url) = DisplaySafeUrl::from_file_path(path) {
write!(f, "{url}")
} else {
write!(f, "{}", path.display())
}
}
Self::Unknown(url) => write!(f, "{url}"),
}
}
}
@@ -121,3 +252,106 @@ impl From<&Self> for ResourceId {
value.clone()
}
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use fs_err as fs;
use super::VersionId;
use uv_redacted::DisplaySafeUrl;
#[test]
fn version_id_ignores_hash_fragments() {
let first = DisplaySafeUrl::parse(
"https://example.com/pkg-0.1.0.whl#sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
.unwrap();
let second = DisplaySafeUrl::parse(
"https://example.com/pkg-0.1.0.whl#sha512=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
)
.unwrap();
assert_eq!(VersionId::from_url(&first), VersionId::from_url(&second));
}
#[test]
fn version_id_preserves_non_hash_fragments() {
let first =
DisplaySafeUrl::parse("https://example.com/pkg-0.1.0.tar.gz#subdirectory=foo").unwrap();
let second =
DisplaySafeUrl::parse("https://example.com/pkg-0.1.0.tar.gz#subdirectory=bar").unwrap();
assert_ne!(VersionId::from_url(&first), VersionId::from_url(&second));
}
#[test]
fn version_id_ignores_hash_fragments_with_subdirectory() {
let first = DisplaySafeUrl::parse(
"https://example.com/pkg-0.1.0.tar.gz#subdirectory=foo&sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
.unwrap();
let second = DisplaySafeUrl::parse(
"https://example.com/pkg-0.1.0.tar.gz#sha512=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&subdirectory=foo",
)
.unwrap();
assert_eq!(VersionId::from_url(&first), VersionId::from_url(&second));
}
#[test]
fn version_id_preserves_non_archive_fragments() {
let first =
DisplaySafeUrl::parse("git+https://example.com/pkg.git#subdirectory=foo").unwrap();
let second =
DisplaySafeUrl::parse("git+https://example.com/pkg.git#subdirectory=bar").unwrap();
assert_ne!(VersionId::from_url(&first), VersionId::from_url(&second));
}
#[test]
fn version_id_ignores_irrelevant_git_fragments() {
let first =
DisplaySafeUrl::parse("git+https://example.com/pkg.git@main#egg=pkg&subdirectory=foo")
.unwrap();
let second =
DisplaySafeUrl::parse("git+https://example.com/pkg.git@main#subdirectory=foo").unwrap();
assert_eq!(VersionId::from_url(&first), VersionId::from_url(&second));
}
#[test]
fn version_id_uses_file_kinds() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("uv-version-id-{nonce}"));
let file = root.join("pkg-0.1.0.whl");
let directory = root.join("pkg");
fs::create_dir_all(&directory).unwrap();
fs::write(&file, b"wheel").unwrap();
let file_url = DisplaySafeUrl::from_file_path(&file).unwrap();
let directory_url = DisplaySafeUrl::from_file_path(&directory).unwrap();
assert!(matches!(VersionId::from_url(&file_url), VersionId::Path(_)));
assert!(matches!(
VersionId::from_url(&directory_url),
VersionId::Directory(_)
));
fs::remove_file(file).unwrap();
fs::remove_dir_all(root).unwrap();
}
#[test]
fn version_id_uses_unknown_for_invalid_git_like_urls() {
let url =
DisplaySafeUrl::parse("git+ftp://example.com/pkg.git@main#subdirectory=foo").unwrap();
assert!(matches!(VersionId::from_url(&url), VersionId::Unknown(_)));
}
}
+49
View File
@@ -901,12 +901,20 @@ impl DistributionMetadata for DirectUrlBuiltDist {
fn version_or_url(&self) -> VersionOrUrlRef<'_> {
VersionOrUrlRef::Url(&self.url)
}
fn version_id(&self) -> VersionId {
VersionId::from_archive(self.location.as_ref(), None)
}
}
impl DistributionMetadata for PathBuiltDist {
fn version_or_url(&self) -> VersionOrUrlRef<'_> {
VersionOrUrlRef::Url(&self.url)
}
fn version_id(&self) -> VersionId {
VersionId::from_path(self.install_path.as_ref())
}
}
impl DistributionMetadata for RegistrySourceDist {
@@ -919,24 +927,40 @@ impl DistributionMetadata for DirectUrlSourceDist {
fn version_or_url(&self) -> VersionOrUrlRef<'_> {
VersionOrUrlRef::Url(&self.url)
}
fn version_id(&self) -> VersionId {
VersionId::from_archive(self.location.as_ref(), self.subdirectory.as_deref())
}
}
impl DistributionMetadata for GitSourceDist {
fn version_or_url(&self) -> VersionOrUrlRef<'_> {
VersionOrUrlRef::Url(&self.url)
}
fn version_id(&self) -> VersionId {
VersionId::from_git(self.git.as_ref(), self.subdirectory.as_deref())
}
}
impl DistributionMetadata for PathSourceDist {
fn version_or_url(&self) -> VersionOrUrlRef<'_> {
VersionOrUrlRef::Url(&self.url)
}
fn version_id(&self) -> VersionId {
VersionId::from_path(self.install_path.as_ref())
}
}
impl DistributionMetadata for DirectorySourceDist {
fn version_or_url(&self) -> VersionOrUrlRef<'_> {
VersionOrUrlRef::Url(&self.url)
}
fn version_id(&self) -> VersionId {
VersionId::from_directory(self.install_path.as_ref())
}
}
impl DistributionMetadata for SourceDist {
@@ -949,6 +973,16 @@ impl DistributionMetadata for SourceDist {
Self::Directory(dist) => dist.version_or_url(),
}
}
fn version_id(&self) -> VersionId {
match self {
Self::Registry(dist) => dist.version_id(),
Self::DirectUrl(dist) => dist.version_id(),
Self::Git(dist) => dist.version_id(),
Self::Path(dist) => dist.version_id(),
Self::Directory(dist) => dist.version_id(),
}
}
}
impl DistributionMetadata for BuiltDist {
@@ -959,6 +993,14 @@ impl DistributionMetadata for BuiltDist {
Self::Path(dist) => dist.version_or_url(),
}
}
fn version_id(&self) -> VersionId {
match self {
Self::Registry(dist) => dist.version_id(),
Self::DirectUrl(dist) => dist.version_id(),
Self::Path(dist) => dist.version_id(),
}
}
}
impl DistributionMetadata for Dist {
@@ -968,6 +1010,13 @@ impl DistributionMetadata for Dist {
Self::Source(dist) => dist.version_or_url(),
}
}
fn version_id(&self) -> VersionId {
match self {
Self::Built(dist) => dist.version_id(),
Self::Source(dist) => dist.version_id(),
}
}
}
impl RemoteSource for File {
@@ -2,7 +2,7 @@ use std::fmt::{Display, Formatter};
use crate::{
Dist, DistributionId, DistributionMetadata, Identifier, InstalledDist, Name, ResourceId,
VersionOrUrlRef,
VersionId, VersionOrUrlRef,
};
use uv_normalize::PackageName;
use uv_pep440::Version;
@@ -43,6 +43,13 @@ impl DistributionMetadata for RequestedDist {
Self::Installable(dist) => dist.version_or_url(),
}
}
fn version_id(&self) -> VersionId {
match self {
Self::Installed(dist) => dist.version_id(),
Self::Installable(dist) => dist.version_id(),
}
}
}
impl Identifier for RequestedDist {
+8 -1
View File
@@ -9,7 +9,7 @@ use uv_pypi_types::Yanked;
use crate::{
BuiltDist, Dist, DistributionId, DistributionMetadata, Identifier, IndexUrl, InstalledDist,
Name, PrioritizedDist, RegistryBuiltWheel, RegistrySourceDist, ResourceId, SourceDist,
VersionOrUrlRef,
VersionId, VersionOrUrlRef,
};
/// A distribution that can be used for resolution and installation.
@@ -215,6 +215,13 @@ impl DistributionMetadata for ResolvedDist {
Self::Installable { dist, .. } => dist.version_or_url(),
}
}
fn version_id(&self) -> VersionId {
match self {
Self::Installed { dist } => dist.version_id(),
Self::Installable { dist, .. } => dist.version_id(),
}
}
}
impl Identifier for ResolvedDist {