diff --git a/Cargo.lock b/Cargo.lock index b7490af03ae23..1acde7e49fc97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6145,6 +6145,7 @@ dependencies = [ "uv-distribution-filename", "uv-distribution-types", "uv-fs", + "uv-git", "uv-metadata", "uv-normalize", "uv-pep440", @@ -6240,6 +6241,7 @@ dependencies = [ "uv-distribution-filename", "uv-distribution-types", "uv-extract", + "uv-git", "uv-installer", "uv-macros", "uv-options-metadata", diff --git a/crates/uv-client/Cargo.toml b/crates/uv-client/Cargo.toml index 7ac63346c2429..3a84349088b29 100644 --- a/crates/uv-client/Cargo.toml +++ b/crates/uv-client/Cargo.toml @@ -26,6 +26,7 @@ uv-configuration = { workspace = true } uv-distribution-filename = { workspace = true } uv-distribution-types = { workspace = true } uv-fs = { workspace = true, features = ["tokio"] } +uv-git = { workspace = true } uv-metadata = { workspace = true } uv-normalize = { workspace = true } uv-pep440 = { workspace = true } diff --git a/crates/uv-client/src/error.rs b/crates/uv-client/src/error.rs index 6542d8a3b6a4d..cd5556a2ce9da 100644 --- a/crates/uv-client/src/error.rs +++ b/crates/uv-client/src/error.rs @@ -382,6 +382,9 @@ pub enum ErrorKind { #[error(transparent)] Flat(#[from] FlatIndexError), + #[error(transparent)] + Git(#[from] uv_git::GitResolverError), + #[error("Expected a file URL, but received: {0}")] NonFileUrl(DisplaySafeUrl), diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs index e5c5ed24e69a1..ea47f66f7ed22 100644 --- a/crates/uv-client/src/registry_client.rs +++ b/crates/uv-client/src/registry_client.rs @@ -24,6 +24,7 @@ use uv_distribution_types::{ BuiltDist, File, IndexCapabilities, IndexFormat, IndexLocations, IndexMetadataRef, IndexStatusCodeDecision, IndexStatusCodeStrategy, IndexUrl, IndexUrls, Name, }; +use uv_git::{GitResolver, Reporter}; use uv_metadata::{read_metadata_async_seek, read_metadata_async_stream}; use uv_normalize::PackageName; use uv_pep440::Version; @@ -909,7 +910,9 @@ impl RegistryClient { pub async fn wheel_metadata( &self, built_dist: &BuiltDist, + git: &GitResolver, capabilities: &IndexCapabilities, + reporter: Option>, ) -> Result { let metadata = match &built_dist { BuiltDist::Registry(wheels) => { @@ -986,6 +989,37 @@ impl RegistryClient { ) })? } + BuiltDist::GitPath(wheel) => { + // Fetch the Git repository. + let fetch = git + .fetch( + &wheel.git, + self.disable_ssl(wheel.git.url()), + self.connectivity() == Connectivity::Offline, + self.cache.bucket(CacheBucket::Git), + reporter, + ) + .await + .map_err(ErrorKind::Git)?; + + // Read the metadata. + let file = fs_err::tokio::File::open(fetch.path().join(&wheel.install_path)) + .await + .map_err(ErrorKind::Io)?; + let reader = tokio::io::BufReader::new(file); + let contents = read_metadata_async_seek(&wheel.filename, reader) + .await + .map_err(|err| { + ErrorKind::Metadata(wheel.install_path.to_string_lossy().to_string(), err) + })?; + ResolutionMetadata::parse_metadata(&contents).map_err(|err| { + ErrorKind::MetadataParseError( + wheel.filename.clone(), + built_dist.to_string(), + Box::new(err), + ) + })? + } }; if metadata.name != *built_dist.name() { diff --git a/crates/uv-client/tests/it/remote_metadata.rs b/crates/uv-client/tests/it/remote_metadata.rs index 068f8f139d5d1..bf06962e30c19 100644 --- a/crates/uv-client/tests/it/remote_metadata.rs +++ b/crates/uv-client/tests/it/remote_metadata.rs @@ -6,6 +6,7 @@ use uv_cache::Cache; use uv_client::{BaseClientBuilder, RegistryClientBuilder}; use uv_distribution_filename::WheelFilename; use uv_distribution_types::{BuiltDist, DirectUrlBuiltDist, IndexCapabilities}; +use uv_git::GitResolver; use uv_pep508::VerbatimUrl; use uv_redacted::DisplaySafeUrl; @@ -24,8 +25,11 @@ async fn remote_metadata_with_and_without_cache() -> Result<()> { location: Box::new(DisplaySafeUrl::parse(url)?), url: VerbatimUrl::from_str(url)?, }); + let resolver = GitResolver::default(); let capabilities = IndexCapabilities::default(); - let metadata = client.wheel_metadata(&dist, &capabilities).await?; + let metadata = client + .wheel_metadata(&dist, &resolver, &capabilities, None) + .await?; assert_eq!(metadata.version.to_string(), "4.66.1"); } diff --git a/crates/uv-dev/Cargo.toml b/crates/uv-dev/Cargo.toml index ae38811d181af..418aab5e9e5ed 100644 --- a/crates/uv-dev/Cargo.toml +++ b/crates/uv-dev/Cargo.toml @@ -22,6 +22,7 @@ uv-configuration = { workspace = true } uv-distribution-filename = { workspace = true } uv-distribution-types = { workspace = true } uv-extract = { workspace = true } +uv-git = { workspace = true } uv-installer = { workspace = true } uv-macros = { workspace = true } uv-options-metadata = { workspace = true } diff --git a/crates/uv-dev/src/wheel_metadata.rs b/crates/uv-dev/src/wheel_metadata.rs index e2d94e1d8f4b9..1cc5ec0afcc4a 100644 --- a/crates/uv-dev/src/wheel_metadata.rs +++ b/crates/uv-dev/src/wheel_metadata.rs @@ -8,6 +8,7 @@ use uv_cache::{Cache, CacheArgs}; use uv_client::{BaseClientBuilder, RegistryClientBuilder}; use uv_distribution_filename::WheelFilename; use uv_distribution_types::{BuiltDist, DirectUrlBuiltDist, IndexCapabilities, RemoteSource}; +use uv_git::GitResolver; use uv_pep508::VerbatimUrl; use uv_pypi_types::ParsedUrl; use uv_settings::EnvironmentOptions; @@ -31,6 +32,7 @@ pub(crate) async fn wheel_metadata( cache, ) .build()?; + let resolver = GitResolver::default(); let capabilities = IndexCapabilities::default(); let filename = WheelFilename::from_str(&args.url.filename()?)?; @@ -46,7 +48,9 @@ pub(crate) async fn wheel_metadata( location: Box::new(archive.url), url: args.url, }), + &resolver, &capabilities, + None, ) .await?; println!("{metadata:?}"); diff --git a/crates/uv-distribution-types/src/any.rs b/crates/uv-distribution-types/src/any.rs index ef9fc3e5d943a..ab86777556fe1 100644 --- a/crates/uv-distribution-types/src/any.rs +++ b/crates/uv-distribution-types/src/any.rs @@ -14,8 +14,8 @@ use crate::{InstalledMetadata, InstalledVersion, Name}; /// kind. #[derive(Debug, Clone, Eq)] pub enum LocalDist { - Cached(CachedDist, CanonicalVersion), - Installed(InstalledDist, CanonicalVersion), + Cached(Box, CanonicalVersion), + Installed(Box, CanonicalVersion), } impl LocalDist { @@ -48,14 +48,14 @@ impl InstalledMetadata for LocalDist { impl From for LocalDist { fn from(dist: CachedDist) -> Self { let version = CanonicalVersion::from(dist.installed_version()); - Self::Cached(dist, version) + Self::Cached(Box::new(dist), version) } } impl From for LocalDist { fn from(dist: InstalledDist) -> Self { let version = CanonicalVersion::from(dist.installed_version()); - Self::Installed(dist, version) + Self::Installed(Box::new(dist), version) } } diff --git a/crates/uv-distribution-types/src/buildable.rs b/crates/uv-distribution-types/src/buildable.rs index 682d7907add59..4475782d01f29 100644 --- a/crates/uv-distribution-types/src/buildable.rs +++ b/crates/uv-distribution-types/src/buildable.rs @@ -9,7 +9,10 @@ use uv_pep508::VerbatimUrl; use uv_normalize::PackageName; use uv_redacted::DisplaySafeUrl; -use crate::{DirectorySourceDist, GitSourceDist, Name, PathSourceDist, SourceDist}; +use crate::{ + DirectorySourceDist, GitDirectorySourceDist, GitPathSourceDist, Name, PathSourceDist, + SourceDist, +}; /// A reference to a source that can be built into a built distribution. /// @@ -96,7 +99,8 @@ impl std::fmt::Display for BuildableSource<'_> { #[derive(Debug, Clone)] pub enum SourceUrl<'a> { Direct(DirectSourceUrl<'a>), - Git(GitSourceUrl<'a>), + GitDirectory(GitDirectorySourceUrl<'a>), + GitPath(GitPathSourceUrl<'a>), Path(PathSourceUrl<'a>), Directory(DirectorySourceUrl<'a>), } @@ -106,7 +110,8 @@ impl SourceUrl<'_> { pub fn url(&self) -> &DisplaySafeUrl { match self { Self::Direct(dist) => dist.url, - Self::Git(dist) => dist.url, + Self::GitDirectory(dist) => dist.url, + Self::GitPath(dist) => dist.url, Self::Path(dist) => dist.url, Self::Directory(dist) => dist.url, } @@ -141,7 +146,8 @@ impl std::fmt::Display for SourceUrl<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Direct(url) => write!(f, "{url}"), - Self::Git(url) => write!(f, "{url}"), + Self::GitDirectory(url) => write!(f, "{url}"), + Self::GitPath(url) => write!(f, "{url}"), Self::Path(url) => write!(f, "{url}"), Self::Directory(url) => write!(f, "{url}"), } @@ -162,7 +168,33 @@ impl std::fmt::Display for DirectSourceUrl<'_> { } #[derive(Debug, Clone)] -pub struct GitSourceUrl<'a> { +pub struct GitPathSourceUrl<'a> { + /// The URL with the revision and path fragment. + pub url: &'a VerbatimUrl, + pub git: &'a GitUrl, + pub path: Cow<'a, Path>, + pub ext: SourceDistExtension, +} + +impl std::fmt::Display for GitPathSourceUrl<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{url}", url = self.url) + } +} + +impl<'a> From<&'a GitPathSourceDist> for GitPathSourceUrl<'a> { + fn from(dist: &'a GitPathSourceDist) -> Self { + Self { + url: &dist.url, + git: &dist.git, + path: Cow::Borrowed(&dist.install_path), + ext: dist.ext, + } + } +} + +#[derive(Debug, Clone)] +pub struct GitDirectorySourceUrl<'a> { /// The URL with the revision and subdirectory fragment. pub url: &'a VerbatimUrl, pub git: &'a GitUrl, @@ -170,14 +202,14 @@ pub struct GitSourceUrl<'a> { pub subdirectory: Option<&'a Path>, } -impl std::fmt::Display for GitSourceUrl<'_> { +impl std::fmt::Display for GitDirectorySourceUrl<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{url}", url = self.url) } } -impl<'a> From<&'a GitSourceDist> for GitSourceUrl<'a> { - fn from(dist: &'a GitSourceDist) -> Self { +impl<'a> From<&'a GitDirectorySourceDist> for GitDirectorySourceUrl<'a> { + fn from(dist: &'a GitDirectorySourceDist) -> Self { Self { url: &dist.url, git: &dist.git, diff --git a/crates/uv-distribution-types/src/cached.rs b/crates/uv-distribution-types/src/cached.rs index 6f3dd1f6aee4b..f6dc31a3ff867 100644 --- a/crates/uv-distribution-types/src/cached.rs +++ b/crates/uv-distribution-types/src/cached.rs @@ -79,6 +79,17 @@ impl CachedDist { build_info, path, }), + Dist::Built(BuiltDist::GitPath(dist)) => Self::Url(CachedDirectUrlDist { + filename, + url: VerbatimParsedUrl { + parsed_url: dist.parsed_url(), + verbatim: dist.url, + }, + hashes, + cache_info, + build_info, + path, + }), Dist::Source(SourceDist::Registry(_dist)) => Self::Registry(CachedRegistryDist { filename, path, @@ -97,7 +108,18 @@ impl CachedDist { build_info, path, }), - Dist::Source(SourceDist::Git(dist)) => Self::Url(CachedDirectUrlDist { + Dist::Source(SourceDist::GitDirectory(dist)) => Self::Url(CachedDirectUrlDist { + filename, + url: VerbatimParsedUrl { + parsed_url: dist.parsed_url(), + verbatim: dist.url, + }, + hashes, + cache_info, + build_info, + path, + }), + Dist::Source(SourceDist::GitPath(dist)) => Self::Url(CachedDirectUrlDist { filename, url: VerbatimParsedUrl { parsed_url: dist.parsed_url(), diff --git a/crates/uv-distribution-types/src/error.rs b/crates/uv-distribution-types/src/error.rs index 34dc29e78098c..f78ec47386037 100644 --- a/crates/uv-distribution-types/src/error.rs +++ b/crates/uv-distribution-types/src/error.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use uv_normalize::PackageName; use uv_redacted::DisplaySafeUrl; @@ -15,6 +16,9 @@ pub enum Error { #[error("Could not extract path segments from URL: {0}")] MissingPathSegments(String), + #[error("Could not extract wheel filename from path: {}", _0.display())] + MissingWheelFilename(PathBuf), + #[error("Distribution not found at: {0}")] NotFound(DisplaySafeUrl), diff --git a/crates/uv-distribution-types/src/hash.rs b/crates/uv-distribution-types/src/hash.rs index f0ba9d019f106..4f4c54df200ce 100644 --- a/crates/uv-distribution-types/src/hash.rs +++ b/crates/uv-distribution-types/src/hash.rs @@ -128,6 +128,18 @@ pub trait Hashed { } } +impl Hashed for Vec { + fn hashes(&self) -> &[HashDigest] { + self + } +} + +impl Hashed for &[HashDigest] { + fn hashes(&self) -> &[HashDigest] { + self + } +} + #[cfg(test)] mod tests { use std::str::FromStr; diff --git a/crates/uv-distribution-types/src/id.rs b/crates/uv-distribution-types/src/id.rs index ad3a14808cb4b..91537bcfe3d04 100644 --- a/crates/uv-distribution-types/src/id.rs +++ b/crates/uv-distribution-types/src/id.rs @@ -7,7 +7,8 @@ use uv_git_types::GitUrl; use uv_normalize::PackageName; use uv_pep440::Version; use uv_pypi_types::{ - HashDigest, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl, + HashDigest, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitDirectoryUrl, ParsedGitPathUrl, + ParsedPathUrl, ParsedUrl, }; use uv_redacted::DisplaySafeUrl; @@ -83,7 +84,8 @@ impl VersionId { 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::GitDirectory(git) => Self::from_git_directory_url(git), + ParsedUrl::GitPath(git) => Self::from_git_path_url(git), ParsedUrl::Archive(archive) => Self::from_archive_url(archive), } } @@ -134,9 +136,13 @@ impl VersionId { Self::from_directory(directory.install_path.as_ref()) } - fn from_git_url(git: &ParsedGitUrl) -> Self { + fn from_git_directory_url(git: &ParsedGitDirectoryUrl) -> Self { Self::from_git(&git.url, git.subdirectory.as_deref()) } + + fn from_git_path_url(git: &ParsedGitPathUrl) -> Self { + Self::from_git(&git.url, Some(&git.install_path)) + } } impl Display for VersionId { diff --git a/crates/uv-distribution-types/src/lib.rs b/crates/uv-distribution-types/src/lib.rs index c397f506448a3..d37d0be547271 100644 --- a/crates/uv-distribution-types/src/lib.rs +++ b/crates/uv-distribution-types/src/lib.rs @@ -16,7 +16,7 @@ //! * [`SourceDist`]: A source distribution, with its four possible origins: //! * [`RegistrySourceDist`] //! * [`DirectUrlSourceDist`] -//! * [`GitSourceDist`] +//! * [`GitPathSourceDist`] //! * [`PathSourceDist`] //! //! ## `CachedDist` @@ -33,9 +33,10 @@ //! //! Since we read this information from [`direct_url.json`](https://packaging.python.org/en/latest/specifications/direct-url-data-structure/), it doesn't match the information [`Dist`] exactly. use std::borrow::Cow; +use std::ffi::OsStr; use std::fmt::Display; use std::path; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::str::FromStr; use url::Url; @@ -49,7 +50,8 @@ use uv_normalize::PackageName; use uv_pep440::Version; use uv_pep508::{Pep508Url, VerbatimUrl}; use uv_pypi_types::{ - ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl, VerbatimParsedUrl, + ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitDirectoryUrl, ParsedGitPathUrl, ParsedPathUrl, + ParsedUrl, VerbatimParsedUrl, }; use uv_redacted::DisplaySafeUrl; @@ -220,6 +222,7 @@ pub enum BuiltDist { Registry(RegistryBuiltDist), DirectUrl(DirectUrlBuiltDist), Path(PathBuiltDist), + GitPath(GitPathBuiltDist), } /// A source distribution, with its possible origins (index, url, path, git) @@ -227,7 +230,8 @@ pub enum BuiltDist { pub enum SourceDist { Registry(RegistrySourceDist), DirectUrl(DirectUrlSourceDist), - Git(GitSourceDist), + GitDirectory(GitDirectorySourceDist), + GitPath(GitPathSourceDist), Path(PathSourceDist), Directory(DirectorySourceDist), } @@ -292,6 +296,18 @@ pub struct PathBuiltDist { pub url: VerbatimUrl, } +/// A source distribution that exists in a Git repository. +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct GitPathBuiltDist { + pub filename: WheelFilename, + /// The URL without the revision and path fragment. + pub git: Box, + /// The path within the Git repository to the distribution which we use for installing. + pub install_path: PathBuf, + /// The URL as it was provided by the user, including the revision and path fragment. + pub url: VerbatimUrl, +} + /// A source distribution that exists in a registry, like `PyPI`. #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct RegistrySourceDist { @@ -327,9 +343,9 @@ pub struct DirectUrlSourceDist { pub url: VerbatimUrl, } -/// A source distribution that exists in a Git repository. +/// A source distribution that exists at the root or in a subdirectory of a Git repository. #[derive(Debug, Clone, Hash, PartialEq, Eq)] -pub struct GitSourceDist { +pub struct GitDirectorySourceDist { pub name: PackageName, /// The URL without the revision and subdirectory fragment. pub git: Box, @@ -339,6 +355,21 @@ pub struct GitSourceDist { pub url: VerbatimUrl, } +/// A source distribution that exists in a local archive (e.g., a `.tar.gz` file) within a Git +/// repository. +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct GitPathSourceDist { + pub name: PackageName, + /// The URL without the revision and subdirectory fragment. + pub git: Box, + /// The path within the Git repository to the distribution which we use for installing. + pub install_path: PathBuf, + /// The file extension, e.g. `tar.gz`, `zip`, etc. + pub ext: SourceDistExtension, + /// The URL as it was provided by the user, including the revision and subdirectory fragment. + pub url: VerbatimUrl, +} + /// A source distribution that exists in a local archive (e.g., a `.tar.gz` file). #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct PathSourceDist { @@ -428,7 +459,11 @@ impl Dist { match ext { DistExtension::Wheel => { // Validate that the name in the wheel matches that of the requirement. - let filename = WheelFilename::from_str(&url.filename()?)?; + let filename = install_path + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| Error::MissingWheelFilename(install_path.clone()))?; + let filename = WheelFilename::from_str(filename)?; if filename.name != name { return Err(Error::PackageNameMismatch( name, @@ -492,19 +527,64 @@ impl Dist { }))) } - /// A remote source distribution from a `git+https://` or `git+ssh://` url. - fn from_git_url( + /// Create a [`Dist`] for a source tree within a Git repository (i.e., a `git+https://` or `git+ssh://` URL). + pub fn from_git_directory_url( name: PackageName, url: VerbatimUrl, git: GitUrl, subdirectory: Option>, - ) -> Self { - Self::Source(SourceDist::Git(GitSourceDist { - name, - git: Box::new(git), - subdirectory, - url, - })) + ) -> Result { + Ok(Self::Source(SourceDist::GitDirectory( + GitDirectorySourceDist { + name, + git: Box::new(git), + subdirectory, + url, + }, + ))) + } + + /// Create a [`Dist`] for a source archive within a Git repository (i.e., a `git+https://` or `git+ssh://` URL). + pub fn from_git_path_url( + name: PackageName, + url: VerbatimUrl, + git: GitUrl, + install_path: PathBuf, + ext: DistExtension, + ) -> Result { + match ext { + DistExtension::Wheel => { + // Validate that the name in the wheel matches that of the requirement. + let filename = install_path + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| Error::MissingWheelFilename(install_path.clone()))?; + let filename = WheelFilename::from_str(filename)?; + if filename.name != name { + return Err(Error::PackageNameMismatch( + name, + filename.name, + url.verbatim().to_string(), + )); + } + + Ok(Self::Built(BuiltDist::GitPath(GitPathBuiltDist { + filename, + git: Box::new(git), + install_path, + url, + }))) + } + DistExtension::Source(ext) => { + Ok(Self::Source(SourceDist::GitPath(GitPathSourceDist { + name, + git: Box::new(git), + install_path, + ext, + url, + }))) + } + } } /// Create a [`Dist`] for a URL-based distribution. @@ -527,12 +607,12 @@ impl Dist { directory.editable, directory.r#virtual, ), - ParsedUrl::Git(git) => Ok(Self::from_git_url( - name, - url.verbatim, - git.url, - git.subdirectory, - )), + ParsedUrl::GitDirectory(git) => { + Self::from_git_directory_url(name, url.verbatim, git.url, git.subdirectory) + } + ParsedUrl::GitPath(git) => { + Self::from_git_path_url(name, url.verbatim, git.url, git.install_path, git.ext) + } } } @@ -626,6 +706,7 @@ impl BuiltDist { Self::Registry(registry) => Some(®istry.best_wheel().index), Self::DirectUrl(_) => None, Self::Path(_) => None, + Self::GitPath(_) => None, } } @@ -633,7 +714,7 @@ impl BuiltDist { pub fn file(&self) -> Option<&File> { match self { Self::Registry(registry) => Some(®istry.best_wheel().file), - Self::DirectUrl(_) | Self::Path(_) => None, + Self::DirectUrl(_) | Self::Path(_) | Self::GitPath(_) => None, } } @@ -642,6 +723,7 @@ impl BuiltDist { Self::Registry(wheels) => &wheels.best_wheel().filename.version, Self::DirectUrl(wheel) => &wheel.filename.version, Self::Path(wheel) => &wheel.filename.version, + Self::GitPath(wheel) => &wheel.filename.version, } } } @@ -652,8 +734,9 @@ impl SourceDist { match self { Self::Registry(source_dist) => Some(source_dist.ext), Self::DirectUrl(source_dist) => Some(source_dist.ext), + Self::GitPath(source_dist) => Some(source_dist.ext), Self::Path(source_dist) => Some(source_dist.ext), - Self::Git(_) | Self::Directory(_) => None, + Self::GitDirectory(_) | Self::Directory(_) => None, } } @@ -661,7 +744,11 @@ impl SourceDist { pub fn index(&self) -> Option<&IndexUrl> { match self { Self::Registry(registry) => Some(®istry.index), - Self::DirectUrl(_) | Self::Git(_) | Self::Path(_) | Self::Directory(_) => None, + Self::DirectUrl(_) + | Self::GitPath(_) + | Self::GitDirectory(_) + | Self::Path(_) + | Self::Directory(_) => None, } } @@ -669,7 +756,11 @@ impl SourceDist { pub fn file(&self) -> Option<&File> { match self { Self::Registry(registry) => Some(®istry.file), - Self::DirectUrl(_) | Self::Git(_) | Self::Path(_) | Self::Directory(_) => None, + Self::DirectUrl(_) + | Self::GitPath(_) + | Self::GitDirectory(_) + | Self::Path(_) + | Self::Directory(_) => None, } } @@ -677,7 +768,11 @@ impl SourceDist { pub fn version(&self) -> Option<&Version> { match self { Self::Registry(source_dist) => Some(&source_dist.version), - Self::DirectUrl(_) | Self::Git(_) | Self::Path(_) | Self::Directory(_) => None, + Self::DirectUrl(_) + | Self::GitPath(_) + | Self::GitDirectory(_) + | Self::Path(_) + | Self::Directory(_) => None, } } @@ -771,16 +866,38 @@ impl DirectUrlSourceDist { } } -impl GitSourceDist { +impl GitDirectorySourceDist { /// Return the [`ParsedUrl`] for the distribution. pub fn parsed_url(&self) -> ParsedUrl { - ParsedUrl::Git(ParsedGitUrl::from_source( + ParsedUrl::GitDirectory(ParsedGitDirectoryUrl::from_source( (*self.git).clone(), self.subdirectory.clone(), )) } } +impl GitPathBuiltDist { + /// Return the [`ParsedUrl`] for the distribution. + pub fn parsed_url(&self) -> ParsedUrl { + ParsedUrl::GitPath(ParsedGitPathUrl::from_source( + (*self.git).clone(), + self.install_path.clone(), + DistExtension::Wheel, + )) + } +} + +impl GitPathSourceDist { + /// Return the [`ParsedUrl`] for the distribution. + pub fn parsed_url(&self) -> ParsedUrl { + ParsedUrl::GitPath(ParsedGitPathUrl::from_source( + (*self.git).clone(), + self.install_path.clone(), + DistExtension::Source(self.ext), + )) + } +} + impl DirectorySourceDist { /// Return the [`ParsedUrl`] for the distribution. pub fn parsed_url(&self) -> ParsedUrl { @@ -817,6 +934,12 @@ impl Name for PathBuiltDist { } } +impl Name for GitPathBuiltDist { + fn name(&self) -> &PackageName { + &self.filename.name + } +} + impl Name for RegistrySourceDist { fn name(&self) -> &PackageName { &self.name @@ -829,7 +952,13 @@ impl Name for DirectUrlSourceDist { } } -impl Name for GitSourceDist { +impl Name for GitPathSourceDist { + fn name(&self) -> &PackageName { + &self.name + } +} + +impl Name for GitDirectorySourceDist { fn name(&self) -> &PackageName { &self.name } @@ -852,7 +981,8 @@ impl Name for SourceDist { match self { Self::Registry(dist) => dist.name(), Self::DirectUrl(dist) => dist.name(), - Self::Git(dist) => dist.name(), + Self::GitPath(dist) => dist.name(), + Self::GitDirectory(dist) => dist.name(), Self::Path(dist) => dist.name(), Self::Directory(dist) => dist.name(), } @@ -865,6 +995,7 @@ impl Name for BuiltDist { Self::Registry(dist) => dist.name(), Self::DirectUrl(dist) => dist.name(), Self::Path(dist) => dist.name(), + Self::GitPath(dist) => dist.name(), } } } @@ -932,6 +1063,12 @@ impl DistributionMetadata for PathBuiltDist { } } +impl DistributionMetadata for GitPathBuiltDist { + fn version_or_url(&self) -> VersionOrUrlRef<'_> { + VersionOrUrlRef::Url(&self.url) + } +} + impl DistributionMetadata for RegistrySourceDist { fn version_or_url(&self) -> VersionOrUrlRef<'_> { VersionOrUrlRef::Version(&self.version) @@ -948,7 +1085,17 @@ impl DistributionMetadata for DirectUrlSourceDist { } } -impl DistributionMetadata for GitSourceDist { +impl DistributionMetadata for GitPathSourceDist { + fn version_or_url(&self) -> VersionOrUrlRef<'_> { + VersionOrUrlRef::Url(&self.url) + } + + fn version_id(&self) -> VersionId { + VersionId::from_git(self.git.as_ref(), Some(&self.install_path)) + } +} + +impl DistributionMetadata for GitDirectorySourceDist { fn version_or_url(&self) -> VersionOrUrlRef<'_> { VersionOrUrlRef::Url(&self.url) } @@ -983,7 +1130,8 @@ impl DistributionMetadata for SourceDist { match self { Self::Registry(dist) => dist.version_or_url(), Self::DirectUrl(dist) => dist.version_or_url(), - Self::Git(dist) => dist.version_or_url(), + Self::GitPath(dist) => dist.version_or_url(), + Self::GitDirectory(dist) => dist.version_or_url(), Self::Path(dist) => dist.version_or_url(), Self::Directory(dist) => dist.version_or_url(), } @@ -993,7 +1141,8 @@ impl DistributionMetadata for SourceDist { match self { Self::Registry(dist) => dist.version_id(), Self::DirectUrl(dist) => dist.version_id(), - Self::Git(dist) => dist.version_id(), + Self::GitPath(dist) => dist.version_id(), + Self::GitDirectory(dist) => dist.version_id(), Self::Path(dist) => dist.version_id(), Self::Directory(dist) => dist.version_id(), } @@ -1006,6 +1155,7 @@ impl DistributionMetadata for BuiltDist { Self::Registry(dist) => dist.version_or_url(), Self::DirectUrl(dist) => dist.version_or_url(), Self::Path(dist) => dist.version_or_url(), + Self::GitPath(dist) => dist.version_or_url(), } } @@ -1014,6 +1164,7 @@ impl DistributionMetadata for BuiltDist { Self::Registry(dist) => dist.version_id(), Self::DirectUrl(dist) => dist.version_id(), Self::Path(dist) => dist.version_id(), + Self::GitPath(dist) => dist.version_id(), } } } @@ -1137,7 +1288,33 @@ impl RemoteSource for DirectUrlSourceDist { } } -impl RemoteSource for GitSourceDist { +impl RemoteSource for GitPathSourceDist { + fn filename(&self) -> Result, Error> { + // The filename is the last segment of the URL, before any `@`. + match self.url.filename()? { + Cow::Borrowed(filename) => { + if let Some((_, filename)) = filename.rsplit_once('@') { + Ok(Cow::Borrowed(filename)) + } else { + Ok(Cow::Borrowed(filename)) + } + } + Cow::Owned(filename) => { + if let Some((_, filename)) = filename.rsplit_once('@') { + Ok(Cow::Owned(filename.to_owned())) + } else { + Ok(Cow::Owned(filename)) + } + } + } + } + + fn size(&self) -> Option { + self.url.size() + } +} + +impl RemoteSource for GitDirectorySourceDist { fn filename(&self) -> Result, Error> { // The filename is the last segment of the URL, before any `@`. match self.url.filename()? { @@ -1173,6 +1350,16 @@ impl RemoteSource for PathBuiltDist { } } +impl RemoteSource for GitPathBuiltDist { + fn filename(&self) -> Result, Error> { + self.url.filename() + } + + fn size(&self) -> Option { + self.url.size() + } +} + impl RemoteSource for PathSourceDist { fn filename(&self) -> Result, Error> { self.url.filename() @@ -1198,7 +1385,8 @@ impl RemoteSource for SourceDist { match self { Self::Registry(dist) => dist.filename(), Self::DirectUrl(dist) => dist.filename(), - Self::Git(dist) => dist.filename(), + Self::GitPath(dist) => dist.filename(), + Self::GitDirectory(dist) => dist.filename(), Self::Path(dist) => dist.filename(), Self::Directory(dist) => dist.filename(), } @@ -1208,7 +1396,8 @@ impl RemoteSource for SourceDist { match self { Self::Registry(dist) => dist.size(), Self::DirectUrl(dist) => dist.size(), - Self::Git(dist) => dist.size(), + Self::GitPath(dist) => dist.size(), + Self::GitDirectory(dist) => dist.size(), Self::Path(dist) => dist.size(), Self::Directory(dist) => dist.size(), } @@ -1221,6 +1410,7 @@ impl RemoteSource for BuiltDist { Self::Registry(dist) => dist.filename(), Self::DirectUrl(dist) => dist.filename(), Self::Path(dist) => dist.filename(), + Self::GitPath(dist) => dist.filename(), } } @@ -1229,6 +1419,7 @@ impl RemoteSource for BuiltDist { Self::Registry(dist) => dist.size(), Self::DirectUrl(dist) => dist.size(), Self::Path(dist) => dist.size(), + Self::GitPath(dist) => dist.size(), } } } @@ -1367,6 +1558,16 @@ impl Identifier for PathBuiltDist { } } +impl Identifier for GitPathBuiltDist { + fn distribution_id(&self) -> DistributionId { + self.url.distribution_id() + } + + fn resource_id(&self) -> ResourceId { + self.url.resource_id() + } +} + impl Identifier for PathSourceDist { fn distribution_id(&self) -> DistributionId { self.url.distribution_id() @@ -1387,7 +1588,17 @@ impl Identifier for DirectorySourceDist { } } -impl Identifier for GitSourceDist { +impl Identifier for GitPathSourceDist { + fn distribution_id(&self) -> DistributionId { + self.url.distribution_id() + } + + fn resource_id(&self) -> ResourceId { + self.url.resource_id() + } +} + +impl Identifier for GitDirectorySourceDist { fn distribution_id(&self) -> DistributionId { self.url.distribution_id() } @@ -1402,7 +1613,8 @@ impl Identifier for SourceDist { match self { Self::Registry(dist) => dist.distribution_id(), Self::DirectUrl(dist) => dist.distribution_id(), - Self::Git(dist) => dist.distribution_id(), + Self::GitPath(dist) => dist.distribution_id(), + Self::GitDirectory(dist) => dist.distribution_id(), Self::Path(dist) => dist.distribution_id(), Self::Directory(dist) => dist.distribution_id(), } @@ -1412,7 +1624,8 @@ impl Identifier for SourceDist { match self { Self::Registry(dist) => dist.resource_id(), Self::DirectUrl(dist) => dist.resource_id(), - Self::Git(dist) => dist.resource_id(), + Self::GitPath(dist) => dist.resource_id(), + Self::GitDirectory(dist) => dist.resource_id(), Self::Path(dist) => dist.resource_id(), Self::Directory(dist) => dist.resource_id(), } @@ -1425,6 +1638,7 @@ impl Identifier for BuiltDist { Self::Registry(dist) => dist.distribution_id(), Self::DirectUrl(dist) => dist.distribution_id(), Self::Path(dist) => dist.distribution_id(), + Self::GitPath(dist) => dist.distribution_id(), } } @@ -1433,6 +1647,7 @@ impl Identifier for BuiltDist { Self::Registry(dist) => dist.resource_id(), Self::DirectUrl(dist) => dist.resource_id(), Self::Path(dist) => dist.resource_id(), + Self::GitPath(dist) => dist.resource_id(), } } } @@ -1473,7 +1688,17 @@ impl Identifier for DirectSourceUrl<'_> { } } -impl Identifier for GitSourceUrl<'_> { +impl Identifier for GitDirectorySourceUrl<'_> { + fn distribution_id(&self) -> DistributionId { + self.url.distribution_id() + } + + fn resource_id(&self) -> ResourceId { + self.url.resource_id() + } +} + +impl Identifier for GitPathSourceUrl<'_> { fn distribution_id(&self) -> DistributionId { self.url.distribution_id() } @@ -1507,7 +1732,8 @@ impl Identifier for SourceUrl<'_> { fn distribution_id(&self) -> DistributionId { match self { Self::Direct(url) => url.distribution_id(), - Self::Git(url) => url.distribution_id(), + Self::GitDirectory(url) => url.distribution_id(), + Self::GitPath(url) => url.distribution_id(), Self::Path(url) => url.distribution_id(), Self::Directory(url) => url.distribution_id(), } @@ -1516,7 +1742,8 @@ impl Identifier for SourceUrl<'_> { fn resource_id(&self) -> ResourceId { match self { Self::Direct(url) => url.resource_id(), - Self::Git(url) => url.resource_id(), + Self::GitDirectory(url) => url.resource_id(), + Self::GitPath(url) => url.resource_id(), Self::Path(url) => url.resource_id(), Self::Directory(url) => url.resource_id(), } diff --git a/crates/uv-distribution-types/src/requirement.rs b/crates/uv-distribution-types/src/requirement.rs index f9843e203cd72..c2d0b1c27f113 100644 --- a/crates/uv-distribution-types/src/requirement.rs +++ b/crates/uv-distribution-types/src/requirement.rs @@ -1,6 +1,6 @@ use std::fmt::{Display, Formatter}; use std::io; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::str::FromStr; use thiserror::Error; @@ -18,8 +18,8 @@ use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError}; use crate::{IndexMetadata, IndexUrl}; use uv_pypi_types::{ - ConflictItem, Hashes, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, - ParsedUrl, ParsedUrlError, VerbatimParsedUrl, + ConflictItem, Hashes, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitDirectoryUrl, + ParsedGitPathUrl, ParsedPathUrl, ParsedUrl, ParsedUrlError, VerbatimParsedUrl, }; #[derive(Debug, Error)] @@ -205,7 +205,8 @@ impl From for uv_pep508::Requirement { Some(VersionOrUrl::VersionSpecifier(specifier)) } RequirementSource::Url { url, .. } - | RequirementSource::Git { url, .. } + | RequirementSource::GitPath { url, .. } + | RequirementSource::GitDirectory { url, .. } | RequirementSource::Path { url, .. } | RequirementSource::Directory { url, .. } => Some(VersionOrUrl::Url(url)), }, @@ -238,17 +239,30 @@ impl From for uv_pep508::Requirement { }), verbatim: url, })), - RequirementSource::Git { + RequirementSource::GitDirectory { git, subdirectory, url, } => Some(VersionOrUrl::Url(VerbatimParsedUrl { - parsed_url: ParsedUrl::Git(ParsedGitUrl { + parsed_url: ParsedUrl::GitDirectory(ParsedGitDirectoryUrl { url: git, subdirectory, }), verbatim: url, })), + RequirementSource::GitPath { + git, + install_path, + ext, + url, + } => Some(VersionOrUrl::Url(VerbatimParsedUrl { + parsed_url: ParsedUrl::GitPath(ParsedGitPathUrl { + url: git, + install_path, + ext, + }), + verbatim: url, + })), RequirementSource::Path { install_path, ext, @@ -338,7 +352,7 @@ impl Display for Requirement { RequirementSource::Url { url, .. } => { write!(f, " @ {url}")?; } - RequirementSource::Git { + RequirementSource::GitDirectory { url: _, git, subdirectory, @@ -358,6 +372,21 @@ impl Display for Requirement { )?; } } + RequirementSource::GitPath { + url: _, + git, + install_path, + ext: _, + } => { + write!(f, " @ git+{}", git.url())?; + if let Some(reference) = git.reference().as_url_rev() { + write!(f, "@{reference}")?; + } + writeln!(f, "#path={}", install_path.display())?; + if git.lfs().enabled() { + writeln!(f, "&lfs=true")?; + } + } RequirementSource::Path { url, .. } => { write!(f, " @ {url}")?; } @@ -430,7 +459,7 @@ impl CacheKey for Requirement { ext.name().cache_key(state); url.cache_key(state); } - RequirementSource::Git { + RequirementSource::GitDirectory { git, subdirectory, url, @@ -448,6 +477,21 @@ impl CacheKey for Requirement { } url.cache_key(state); } + RequirementSource::GitPath { + git, + install_path, + ext, + url, + } => { + 5u8.cache_key(state); + git.to_string().cache_key(state); + install_path.cache_key(state); + ext.name().cache_key(state); + if git.lfs().enabled() { + 1u8.cache_key(state); + } + url.cache_key(state); + } RequirementSource::Path { install_path, ext, @@ -512,8 +556,8 @@ pub enum RequirementSource { /// `:///#subdirectory=`. url: VerbatimUrl, }, - /// A remote Git repository, over either HTTPS or SSH. - Git { + /// A remote Git source tree, over either HTTPS or SSH. + GitDirectory { /// The repository URL and reference to the commit to use. git: GitUrl, /// The path to the source distribution if it is not in the repository root. @@ -522,6 +566,18 @@ pub enum RequirementSource { /// `git+:///@#subdirectory=`. url: VerbatimUrl, }, + /// A remote Git archive, over either HTTPS or SSH. + GitPath { + /// The repository URL and reference to the commit to use. + git: GitUrl, + /// The path to the file in the repository. + install_path: PathBuf, + /// The file extension, e.g. `tar.gz`, `zip`, etc. + ext: DistExtension, + /// The PEP 508 style url in the format + /// `git+:///@#subdirectory=`. + url: VerbatimUrl, + }, /// A local built or source distribution, either from a path or a `file://` URL. It can either /// be a binary distribution (a `.whl` file) or a source distribution archive (a `.zip` or /// `.tar.gz` file). @@ -565,11 +621,17 @@ impl RequirementSource { r#virtual: directory.r#virtual, url, }, - ParsedUrl::Git(git) => Self::Git { - git: git.url.clone(), + ParsedUrl::GitDirectory(git) => Self::GitDirectory { url, + git: git.url, subdirectory: git.subdirectory, }, + ParsedUrl::GitPath(git) => Self::GitPath { + url, + git: git.url, + install_path: git.install_path.clone(), + ext: git.ext, + }, ParsedUrl::Archive(archive) => Self::Url { url, location: archive.url, @@ -622,17 +684,30 @@ impl RequirementSource { )), verbatim: url.clone(), }), - Self::Git { + Self::GitDirectory { git, subdirectory, url, } => Some(VerbatimParsedUrl { - parsed_url: ParsedUrl::Git(ParsedGitUrl::from_source( + parsed_url: ParsedUrl::GitDirectory(ParsedGitDirectoryUrl::from_source( git.clone(), subdirectory.clone(), )), verbatim: url.clone(), }), + Self::GitPath { + git, + install_path, + ext, + url, + } => Some(VerbatimParsedUrl { + parsed_url: ParsedUrl::GitPath(ParsedGitPathUrl::from_source( + git.clone(), + install_path.clone(), + *ext, + )), + verbatim: url.clone(), + }), } } @@ -648,9 +723,11 @@ impl RequirementSource { Some(VersionOrUrl::VersionSpecifier(specifier.clone())) } } - Self::Url { .. } | Self::Git { .. } | Self::Path { .. } | Self::Directory { .. } => { - Some(VersionOrUrl::Url(self.to_verbatim_parsed_url()?)) - } + Self::Url { .. } + | Self::GitPath { .. } + | Self::GitDirectory { .. } + | Self::Path { .. } + | Self::Directory { .. } => Some(VersionOrUrl::Url(self.to_verbatim_parsed_url()?)), } } @@ -669,9 +746,11 @@ impl RequirementSource { pub fn is_empty(&self) -> bool { match self { Self::Registry { specifier, .. } => specifier.is_empty(), - Self::Url { .. } | Self::Git { .. } | Self::Path { .. } | Self::Directory { .. } => { - false - } + Self::Url { .. } + | Self::GitPath { .. } + | Self::GitDirectory { .. } + | Self::Path { .. } + | Self::Directory { .. } => false, } } @@ -679,16 +758,21 @@ impl RequirementSource { pub fn version_specifiers(&self) -> Option<&VersionSpecifiers> { match self { Self::Registry { specifier, .. } => Some(specifier), - Self::Url { .. } | Self::Git { .. } | Self::Path { .. } | Self::Directory { .. } => { - None - } + Self::Url { .. } + | Self::GitPath { .. } + | Self::GitDirectory { .. } + | Self::Path { .. } + | Self::Directory { .. } => None, } } /// Convert the source to a [`RequirementSource`] relative to the given path. pub fn relative_to(self, path: &Path) -> Result { match self { - Self::Registry { .. } | Self::Url { .. } | Self::Git { .. } => Ok(self), + Self::Registry { .. } + | Self::Url { .. } + | Self::GitPath { .. } + | Self::GitDirectory { .. } => Ok(self), Self::Path { install_path, ext, @@ -719,7 +803,10 @@ impl RequirementSource { #[must_use] pub fn to_absolute(self, root: &Path) -> Self { match self { - Self::Registry { .. } | Self::Url { .. } | Self::Git { .. } => self, + Self::Registry { .. } + | Self::Url { .. } + | Self::GitPath { .. } + | Self::GitDirectory { .. } => self, Self::Path { install_path, ext, @@ -765,7 +852,7 @@ impl Display for RequirementSource { Self::Url { url, .. } => { write!(f, " {url}")?; } - Self::Git { + Self::GitDirectory { url: _, git, subdirectory, @@ -785,6 +872,18 @@ impl Display for RequirementSource { )?; } } + Self::GitPath { + url: _, + git, + install_path, + ext: _, + } => { + write!(f, " git+{}", git.url())?; + if let Some(reference) = git.reference().as_url_rev() { + write!(f, "@{reference}")?; + } + writeln!(f, "#path={}", install_path.display())?; + } Self::Path { url, .. } => { write!(f, "{url}")?; } @@ -850,7 +949,7 @@ impl From for RequirementSourceWire { url: location, subdirectory: subdirectory.map(PortablePathBuf::from), }, - RequirementSource::Git { + RequirementSource::GitDirectory { git, subdirectory, url: _, @@ -905,6 +1004,56 @@ impl From for RequirementSourceWire { git: url.to_string(), } } + RequirementSource::GitPath { + git, + install_path, + ext: _, + url: _, + } => { + let mut url = git.url().clone(); + + // Remove the credentials. + url.remove_credentials(); + + // Clear out any existing state. + url.set_fragment(None); + url.set_query(None); + + // Put the path in the query. + if let Some(install_path) = install_path.to_str() { + url.query_pairs_mut().append_pair("path", install_path); + } + + // Put the requested reference in the query. + match git.reference() { + GitReference::Branch(branch) => { + url.query_pairs_mut().append_pair("branch", branch.as_str()); + } + GitReference::Tag(tag) => { + url.query_pairs_mut().append_pair("tag", tag.as_str()); + } + GitReference::BranchOrTag(rev) + | GitReference::BranchOrTagOrCommit(rev) + | GitReference::NamedRef(rev) => { + url.query_pairs_mut().append_pair("rev", rev.as_str()); + } + GitReference::DefaultBranch => {} + } + + // Persist lfs=true in the distribution metadata only when explicitly enabled. + if git.lfs().enabled() { + url.query_pairs_mut().append_pair("lfs", "true"); + } + + // Put the precise commit in the fragment. + if let Some(precise) = git.precise() { + url.set_fragment(Some(&precise.to_string())); + } + + Self::Git { + git: url.to_string(), + } + } RequirementSource::Path { install_path, ext: _, @@ -957,6 +1106,7 @@ impl TryFrom for RequirementSource { let mut reference = GitReference::DefaultBranch; let mut subdirectory: Option = None; let mut lfs = GitLfs::Disabled; + let mut path: Option = None; for (key, val) in repository.query_pairs() { match &*key { "tag" => reference = GitReference::Tag(val.into_owned()), @@ -966,6 +1116,9 @@ impl TryFrom for RequirementSource { subdirectory = Some(PortablePathBuf::from(val.as_ref())); } "lfs" => lfs = GitLfs::from(val.eq_ignore_ascii_case("true")), + "path" => { + path = Some(PortablePathBuf::from(val.as_ref())); + } _ => {} } } @@ -993,17 +1146,31 @@ impl TryFrom for RequirementSource { if lfs.enabled() { frags.push("lfs=true".to_string()); } + if let Some(path) = path.as_ref() { + frags.push(format!("path={path}")); + } if !frags.is_empty() { url.set_fragment(Some(&frags.join("&"))); } - let url = VerbatimUrl::from_url(url); + let git = GitUrl::from_fields(repository, reference, precise, lfs)?; - Ok(Self::Git { - git: GitUrl::from_fields(repository, reference, precise, lfs)?, - subdirectory: subdirectory.map(Box::::from), - url, - }) + if let Some(install_path) = path.map(Box::::from).map(PathBuf::from) { + Ok(Self::GitPath { + git, + ext: DistExtension::from_path(install_path.as_path()).map_err(|err| { + ParsedUrlError::MissingExtensionPath(install_path.clone(), err) + })?, + install_path, + url, + }) + } else { + Ok(Self::GitDirectory { + git, + subdirectory: subdirectory.map(Box::::from), + url, + }) + } } RequirementSourceWire::Direct { url, subdirectory } => { let location = url.clone(); diff --git a/crates/uv-distribution-types/src/resolution.rs b/crates/uv-distribution-types/src/resolution.rs index e11d194cfa30e..611165e449694 100644 --- a/crates/uv-distribution-types/src/resolution.rs +++ b/crates/uv-distribution-types/src/resolution.rs @@ -240,6 +240,12 @@ impl From<&ResolvedDist> for RequirementSource { url: wheel.url.clone(), ext: DistExtension::Wheel, }, + Dist::Built(BuiltDist::GitPath(wheel)) => Self::GitPath { + url: wheel.url.clone(), + git: (*wheel.git).clone(), + install_path: wheel.install_path.clone(), + ext: DistExtension::Wheel, + }, Dist::Source(SourceDist::Registry(sdist)) => Self::Registry { specifier: uv_pep440::VersionSpecifiers::from( uv_pep440::VersionSpecifier::equals_version(sdist.version.clone()), @@ -257,7 +263,13 @@ impl From<&ResolvedDist> for RequirementSource { ext: DistExtension::Source(sdist.ext), } } - Dist::Source(SourceDist::Git(sdist)) => Self::Git { + Dist::Source(SourceDist::GitPath(sdist)) => Self::GitPath { + url: sdist.url.clone(), + git: (*sdist.git).clone(), + install_path: sdist.install_path.clone(), + ext: DistExtension::Source(sdist.ext), + }, + Dist::Source(SourceDist::GitDirectory(sdist)) => Self::GitDirectory { git: (*sdist.git).clone(), url: sdist.url.clone(), subdirectory: sdist.subdirectory.clone(), diff --git a/crates/uv-distribution-types/src/specified_requirement.rs b/crates/uv-distribution-types/src/specified_requirement.rs index b1c678195b168..a5d2887a459e7 100644 --- a/crates/uv-distribution-types/src/specified_requirement.rs +++ b/crates/uv-distribution-types/src/specified_requirement.rs @@ -98,7 +98,7 @@ impl UnresolvedRequirement { }) .unwrap_or(requirement.marker), source: match requirement.source { - RequirementSource::Git { + RequirementSource::GitDirectory { git, subdirectory, url, @@ -113,12 +113,35 @@ impl UnresolvedRequirement { } else { git }; - RequirementSource::Git { + RequirementSource::GitDirectory { git, subdirectory, url, } } + RequirementSource::GitPath { + git, + install_path, + ext, + url, + } => { + let git = if let Some(git_reference) = git_reference { + git.with_reference(git_reference) + } else { + git + }; + let git = if let Some(lfs) = lfs { + git.with_lfs(GitLfs::from(lfs)) + } else { + git + }; + RequirementSource::GitPath { + git, + install_path, + ext, + url, + } + } _ => requirement.source, }, ..requirement @@ -131,7 +154,19 @@ impl UnresolvedRequirement { }) .unwrap_or(requirement.marker), url: match requirement.url.parsed_url { - ParsedUrl::Git(mut git) => { + ParsedUrl::GitDirectory(mut git) => { + if let Some(git_reference) = git_reference { + git.url = git.url.with_reference(git_reference); + } + if let Some(lfs) = lfs { + git.url = git.url.with_lfs(GitLfs::from(lfs)); + } + VerbatimParsedUrl { + parsed_url: ParsedUrl::GitDirectory(git), + verbatim: requirement.url.verbatim, + } + } + ParsedUrl::GitPath(mut git) => { if let Some(git_reference) = git_reference { git.url = git.url.with_reference(git_reference); } @@ -139,7 +174,7 @@ impl UnresolvedRequirement { git.url = git.url.with_lfs(GitLfs::from(lfs)); } VerbatimParsedUrl { - parsed_url: ParsedUrl::Git(git), + parsed_url: ParsedUrl::GitPath(git), verbatim: requirement.url.verbatim, } } diff --git a/crates/uv-distribution-types/src/traits.rs b/crates/uv-distribution-types/src/traits.rs index af2bde5af7d42..4e8f2e8c3cbae 100644 --- a/crates/uv-distribution-types/src/traits.rs +++ b/crates/uv-distribution-types/src/traits.rs @@ -6,11 +6,11 @@ use uv_pep508::VerbatimUrl; use crate::error::Error; use crate::{ BuiltDist, CachedDirectUrlDist, CachedDist, CachedRegistryDist, DirectUrlBuiltDist, - DirectUrlSourceDist, DirectorySourceDist, Dist, DistributionId, GitSourceDist, - InstalledDirectUrlDist, InstalledDist, InstalledEggInfoDirectory, InstalledEggInfoFile, - InstalledLegacyEditable, InstalledRegistryDist, InstalledVersion, LocalDist, PackageId, - PathBuiltDist, PathSourceDist, RegistryBuiltWheel, RegistrySourceDist, ResourceId, SourceDist, - VersionId, VersionOrUrlRef, + DirectUrlSourceDist, DirectorySourceDist, Dist, DistributionId, GitDirectorySourceDist, + GitPathBuiltDist, GitPathSourceDist, InstalledDirectUrlDist, InstalledDist, + InstalledEggInfoDirectory, InstalledEggInfoFile, InstalledLegacyEditable, + InstalledRegistryDist, InstalledVersion, LocalDist, PackageId, PathBuiltDist, PathSourceDist, + RegistryBuiltWheel, RegistrySourceDist, ResourceId, SourceDist, VersionId, VersionOrUrlRef, }; pub trait Name { @@ -166,7 +166,19 @@ impl std::fmt::Display for Dist { } } -impl std::fmt::Display for GitSourceDist { +impl std::fmt::Display for GitPathBuiltDist { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}{}", self.name(), self.version_or_url()) + } +} + +impl std::fmt::Display for GitPathSourceDist { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}{}", self.name(), self.version_or_url()) + } +} + +impl std::fmt::Display for GitDirectorySourceDist { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}{}", self.name(), self.version_or_url()) } diff --git a/crates/uv-distribution/src/distribution_database.rs b/crates/uv-distribution/src/distribution_database.rs index ba087a6eb4afb..b4ec52e861ba2 100644 --- a/crates/uv-distribution/src/distribution_database.rs +++ b/crates/uv-distribution/src/distribution_database.rs @@ -359,6 +359,40 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> { } } + BuiltDist::GitPath(wheel) => { + // Fetch the Git repository. + let fetch = self + .build_context + .git() + .fetch( + &wheel.git, + self.client.unmanaged.disable_ssl(wheel.git.url()), + self.client.unmanaged.connectivity() == Connectivity::Offline, + self.build_context.cache().bucket(CacheBucket::Git), + self.reporter.clone().map(::into_git_reporter), + ) + .await?; + + let git_sha = fetch.git().precise().expect("Exact commit after checkout"); + let cache_entry = self.build_context.cache().entry( + CacheBucket::Wheels, + WheelCache::Git(&wheel.url, git_sha.as_short_str()).root(), + wheel.filename.stem(), + ); + + let install_path = fetch.path().join(&wheel.install_path); + + self.load_wheel( + &install_path, + &wheel.filename, + WheelExtension::Whl, + cache_entry, + dist, + hashes, + ) + .await + } + BuiltDist::Path(wheel) => { let cache_entry = self.build_context.cache().entry( CacheBucket::Wheels, @@ -563,7 +597,12 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> { .client .managed(|client| { client - .wheel_metadata(dist, self.build_context.capabilities()) + .wheel_metadata( + dist, + self.build_context.git(), + self.build_context.capabilities(), + self.reporter.clone().map(::into_git_reporter), + ) .boxed_local() }) .await; @@ -1045,12 +1084,12 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> { // Attempt to read the archive pointer from the cache. let pointer_entry = wheel_entry.with_file(format!("{}.rev", filename.cache_key())); - let pointer = LocalArchivePointer::read_from(&pointer_entry)?; + let pointer = PathArchivePointer::read_from(&pointer_entry)?; // Extract the archive from the pointer. let archive = pointer .filter(|pointer| pointer.is_up_to_date(modified)) - .map(LocalArchivePointer::into_archive) + .map(PathArchivePointer::into_archive) .filter(|archive| archive.has_digests(hashes)); // If the file is already unzipped, and the cache is up-to-date, return it. @@ -1077,7 +1116,7 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> { ); // Write the archive pointer to the cache. - let pointer = LocalArchivePointer { + let pointer = PathArchivePointer { timestamp: modified, archive: archive.clone(), }; @@ -1144,7 +1183,7 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> { let archive = Archive::new(id, hashes, filename.clone()); // Write the archive pointer to the cache. - let pointer = LocalArchivePointer { + let pointer = PathArchivePointer { timestamp: modified, archive: archive.clone(), }; @@ -1352,13 +1391,13 @@ impl HttpArchivePointer { /// /// Encoded with `MsgPack`, and represented on disk by a `.rev` file. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct LocalArchivePointer { +pub struct PathArchivePointer { timestamp: Timestamp, archive: Archive, } -impl LocalArchivePointer { - /// Read an [`LocalArchivePointer`] from the cache. +impl PathArchivePointer { + /// Read an [`PathArchivePointer`] from the cache. pub fn read_from(path: impl AsRef) -> Result, Error> { match fs_err::read(path) { Ok(cached) => Ok(Some(rmp_serde::from_slice::(&cached)?)), @@ -1367,7 +1406,7 @@ impl LocalArchivePointer { } } - /// Write an [`LocalArchivePointer`] to the cache. + /// Write an [`PathArchivePointer`] to the cache. pub(crate) async fn write_to(&self, entry: &CacheEntry) -> Result<(), Error> { write_atomic(entry.path(), rmp_serde::to_vec(&self)?) .await diff --git a/crates/uv-distribution/src/index/built_wheel_index.rs b/crates/uv-distribution/src/index/built_wheel_index.rs index 0cdb27da0f5f4..707c289cbf859 100644 --- a/crates/uv-distribution/src/index/built_wheel_index.rs +++ b/crates/uv-distribution/src/index/built_wheel_index.rs @@ -4,8 +4,8 @@ use uv_cache::{Cache, CacheBucket, CacheShard, WheelCache}; use uv_cache_info::CacheInfo; use uv_distribution_types::{ BuildInfo, BuildVariables, ConfigSettings, DirectUrlSourceDist, DirectorySourceDist, - ExtraBuildRequirement, ExtraBuildRequires, ExtraBuildVariables, GitSourceDist, Hashed, - PackageConfigSettings, PathSourceDist, + ExtraBuildRequirement, ExtraBuildRequires, ExtraBuildVariables, GitDirectorySourceDist, + GitPathSourceDist, Hashed, PackageConfigSettings, PathSourceDist, }; use uv_normalize::PackageName; use uv_platform_tags::Tags; @@ -14,7 +14,10 @@ use uv_types::HashStrategy; use crate::Error; use crate::index::cached_wheel::{CachedWheel, ResolvedWheel}; -use crate::source::{HTTP_REVISION, HttpRevisionPointer, LOCAL_REVISION, LocalRevisionPointer}; +use crate::source::{ + HASHES, HTTP_REVISION, HttpRevisionPointer, LOCAL_REVISION, LocalRevisionPointer, + RevisionHashes, +}; /// A local index of built distributions for a specific source distribution. #[derive(Debug)] @@ -192,7 +195,7 @@ impl<'a> BuiltWheelIndex<'a> { } /// Return the most compatible [`CachedWheel`] for a given source distribution at a git URL. - pub fn git(&self, source_dist: &GitSourceDist) -> Option { + pub fn git_directory(&self, source_dist: &GitDirectorySourceDist) -> Option { // Enforce hash-checking, which isn't supported for Git distributions. if self.hasher.get(source_dist).requires_validation() { return None; @@ -226,6 +229,48 @@ impl<'a> BuiltWheelIndex<'a> { }) } + /// Return the most compatible [`CachedWheel`] for a given source distribution at a git URL. + pub fn git_path(&self, source_dist: &GitPathSourceDist) -> Result, Error> { + let Some(git_sha) = source_dist.git.precise() else { + return Ok(None); + }; + + let cache_shard = self.cache.shard( + CacheBucket::SourceDistributions, + WheelCache::Git(&source_dist.url, git_sha.as_short_str()).root(), + ); + + // Read the revision from the cache. + let Some(revision) = RevisionHashes::read_from(cache_shard.entry(HASHES))? else { + return Ok(None); + }; + + // Enforce hash-checking by omitting any wheels that don't satisfy the required hashes. + if !revision.satisfies(self.hasher.get(source_dist)) { + return Ok(None); + } + + // If there are build settings, we need to scope to a cache shard. + let config_settings = self.config_settings_for(&source_dist.name); + let extra_build_deps = self.extra_build_requires_for(&source_dist.name); + let extra_build_vars = self.extra_build_variables_for(&source_dist.name); + let build_info = + BuildInfo::from_settings(&config_settings, extra_build_deps, extra_build_vars); + let cache_shard = build_info + .cache_shard() + .map(|digest| cache_shard.shard(digest)) + .unwrap_or(cache_shard); + + Ok(self.find(&cache_shard).map(|wheel| { + CachedWheel::from_entry( + wheel, + revision.into_hashes(), + CacheInfo::default(), + build_info, + ) + })) + } + /// Find the "best" distribution in the index for a given source distribution. /// /// This lookup prefers newer versions over older versions, and aims to maximize compatibility diff --git a/crates/uv-distribution/src/index/cached_wheel.rs b/crates/uv-distribution/src/index/cached_wheel.rs index eb32e51c4dc83..57a2bd21f54bb 100644 --- a/crates/uv-distribution/src/index/cached_wheel.rs +++ b/crates/uv-distribution/src/index/cached_wheel.rs @@ -5,12 +5,12 @@ use uv_cache_info::CacheInfo; use uv_distribution_filename::WheelFilename; use uv_distribution_types::{ BuildInfo, CachedDirectUrlDist, CachedRegistryDist, DirectUrlSourceDist, DirectorySourceDist, - GitSourceDist, Hashed, PathSourceDist, + GitDirectorySourceDist, GitPathSourceDist, Hashed, PathSourceDist, }; use uv_pypi_types::{HashDigest, HashDigests, VerbatimParsedUrl}; use crate::archive::Archive; -use crate::{HttpArchivePointer, LocalArchivePointer}; +use crate::{HttpArchivePointer, PathArchivePointer}; #[derive(Debug, Clone)] pub(crate) struct ResolvedWheel { @@ -105,7 +105,7 @@ impl CachedWheel { let path = path.as_ref(); // Read the pointer. - let pointer = LocalArchivePointer::read_from(path).ok()??; + let pointer = PathArchivePointer::read_from(path).ok()??; let cache_info = pointer.to_cache_info(); let build_info = pointer.to_build_info(); let archive = pointer.into_archive(); @@ -188,8 +188,24 @@ impl CachedWheel { } /// Convert a [`CachedWheel`] into a [`CachedDirectUrlDist`] by merging in the given - /// [`GitSourceDist`]. - pub fn into_git_dist(self, dist: &GitSourceDist) -> CachedDirectUrlDist { + /// [`GitDirectorySourceDist`]. + pub fn into_git_dist(self, dist: &GitDirectorySourceDist) -> CachedDirectUrlDist { + CachedDirectUrlDist { + filename: self.filename, + url: VerbatimParsedUrl { + parsed_url: dist.parsed_url(), + verbatim: dist.url.clone(), + }, + path: self.entry.into_path_buf().into_boxed_path(), + hashes: self.hashes, + cache_info: self.cache_info, + build_info: self.build_info, + } + } + + /// Convert a [`CachedWheel`] into a [`CachedDirectUrlDist`] by merging in the given + /// [`GitPathSourceDist`]. + pub fn into_git_path_dist(self, dist: &GitPathSourceDist) -> CachedDirectUrlDist { CachedDirectUrlDist { filename: self.filename, url: VerbatimParsedUrl { diff --git a/crates/uv-distribution/src/lib.rs b/crates/uv-distribution/src/lib.rs index 6ffb2d6d87682..a938a450855f1 100644 --- a/crates/uv-distribution/src/lib.rs +++ b/crates/uv-distribution/src/lib.rs @@ -1,4 +1,4 @@ -pub use distribution_database::{DistributionDatabase, HttpArchivePointer, LocalArchivePointer}; +pub use distribution_database::{DistributionDatabase, HttpArchivePointer, PathArchivePointer}; pub use download::LocalWheel; pub use error::Error; pub use index::{BuiltWheelIndex, RegistryWheelIndex}; diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index 11be9d9270283..c33ab7f6eac74 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -15,7 +15,10 @@ use uv_git_types::{GitLfs, GitReference, GitUrl, GitUrlParseError}; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::VersionSpecifiers; use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl, looks_like_git_repository}; -use uv_pypi_types::{ConflictItem, ParsedGitUrl, ParsedUrl, ParsedUrlError, VerbatimParsedUrl}; +use uv_pypi_types::{ + ConflictItem, ParsedGitDirectoryUrl, ParsedGitPathUrl, ParsedUrl, ParsedUrlError, + VerbatimParsedUrl, +}; use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError}; use uv_workspace::Workspace; use uv_workspace::pyproject::{PyProjectToml, Source, Sources}; @@ -170,6 +173,7 @@ impl LoweredRequirement { Source::Git { git, subdirectory, + path, rev, tag, branch, @@ -180,6 +184,7 @@ impl LoweredRequirement { let source = git_source( &git, subdirectory.map(Box::::from), + path.map(Box::::from).map(PathBuf::from), rev, tag, branch, @@ -307,7 +312,7 @@ impl LoweredRequirement { uv_fs::relative_to(member.root(), git_member.fetch_root) .expect("Workspace member must be relative"); let subdirectory = normalize_path(subdirectory); - RequirementSource::Git { + RequirementSource::GitDirectory { git: git_member.git_source.git.clone(), subdirectory: if subdirectory == PathBuf::new() { None @@ -416,6 +421,7 @@ impl LoweredRequirement { Source::Git { git, subdirectory, + path, rev, tag, branch, @@ -426,6 +432,7 @@ impl LoweredRequirement { let source = git_source( &git, subdirectory.map(Box::::from), + path.map(Box::::from).map(PathBuf::from), rev, tag, branch, @@ -665,6 +672,7 @@ fn missing_index_hint(locations: &IndexLocations, index: &IndexName) -> Option>, + path: Option, rev: Option, tag: Option, branch: Option, @@ -699,19 +707,40 @@ fn git_source( if lfs.enabled() { frags.push("lfs=true".to_string()); } + if let Some(path) = path.as_ref() { + let path = path + .to_str() + .ok_or_else(|| LoweringError::NonUtf8Path(path.clone()))?; + frags.push(format!("path={path}")); + } if !frags.is_empty() { url.set_fragment(Some(&frags.join("&"))); } - let url = VerbatimUrl::from_url(url); let repository = git.clone(); + let git = GitUrl::from_fields(repository, reference, None, lfs)?; - Ok(RequirementSource::Git { - url, - git: GitUrl::from_fields(repository, reference, None, lfs)?, - subdirectory, - }) + if let Some(path) = path { + let ext = match DistExtension::from_path(&path) { + Ok(ext) => ext, + Err(err) => { + return Err(ParsedUrlError::MissingExtensionPath(path, err).into()); + } + }; + Ok(RequirementSource::GitPath { + url, + git, + install_path: path, + ext, + }) + } else { + Ok(RequirementSource::GitDirectory { + url, + git, + subdirectory, + }) + } } /// Convert a URL source into a [`RequirementSource`]. @@ -840,9 +869,8 @@ fn path_source( }) } } else { - // TODO(charlie): If a Git repo contains a source that points to a file, what should we do? - if git_member.is_some() { - return Err(LoweringError::GitFile(url.to_string())); + if let Some(git_member) = git_member { + return git_path_source_from_path(install_path, git_member); } if editable == Some(true) { return Err(LoweringError::EditableFile(url.to_string())); @@ -874,17 +902,42 @@ fn git_source_from_path( } else { Some(subdirectory.into_owned().into_boxed_path()) }; - let url = DisplaySafeUrl::from(ParsedGitUrl { + let url = DisplaySafeUrl::from(ParsedGitDirectoryUrl { url: git.clone(), subdirectory: subdirectory.clone(), }); - Ok(RequirementSource::Git { + Ok(RequirementSource::GitDirectory { git, subdirectory, url: VerbatimUrl::from_url(url), }) } +fn git_path_source_from_path( + install_path: impl AsRef, + git_member: &GitWorkspaceMember, +) -> Result { + let git = git_member.git_source.git.clone(); + let install_path = git_path(install_path.as_ref())?; + let fetch_root = git_path(git_member.fetch_root)?; + let install_path = + uv_fs::relative_to(install_path, fetch_root).map_err(LoweringError::RelativeTo)?; + let install_path = normalize_path(install_path).into_owned(); + let ext = DistExtension::from_path(&install_path) + .map_err(|err| ParsedUrlError::MissingExtensionPath(install_path.clone(), err))?; + let url = DisplaySafeUrl::from(ParsedGitPathUrl { + url: git.clone(), + install_path: install_path.clone(), + ext, + }); + Ok(RequirementSource::GitPath { + git, + install_path, + ext, + url: VerbatimUrl::from_url(url), + }) +} + fn git_path(path: &Path) -> Result { path.simple_canonicalize() .or_else(|_| normalize_absolute_path(path)) diff --git a/crates/uv-distribution/src/metadata/mod.rs b/crates/uv-distribution/src/metadata/mod.rs index 802cced8a3e28..4a0c2016ed484 100644 --- a/crates/uv-distribution/src/metadata/mod.rs +++ b/crates/uv-distribution/src/metadata/mod.rs @@ -5,7 +5,7 @@ use thiserror::Error; use uv_auth::CredentialsCache; use uv_configuration::NoSources; -use uv_distribution_types::{GitSourceUrl, IndexLocations, Requirement}; +use uv_distribution_types::{GitDirectorySourceUrl, IndexLocations, Requirement}; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pypi_types::{HashDigests, ResolutionMetadata}; @@ -177,5 +177,5 @@ pub struct GitWorkspaceMember<'a> { /// The root of the checkout, which may be the root of the workspace or may be above the /// workspace root. pub fetch_root: &'a Path, - pub git_source: &'a GitSourceUrl<'a>, + pub git_source: &'a GitDirectorySourceUrl<'a>, } diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index 4613fedeab1fb..a2a8ddf0e379b 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -31,12 +31,12 @@ use uv_configuration::{BuildKind, BuildOutput, NoSources}; use uv_distribution_filename::{SourceDistExtension, WheelFilename}; use uv_distribution_types::{ BuildInfo, BuildVariables, BuildableSource, ConfigSettings, DirectorySourceUrl, - ExtraBuildRequirement, GitSourceUrl, HashPolicy, Hashed, IndexUrl, PathSourceUrl, SourceDist, - SourceUrl, + ExtraBuildRequirement, GitDirectorySourceUrl, GitPathSourceUrl, HashPolicy, Hashed, IndexUrl, + PathSourceUrl, SourceDist, SourceUrl, }; use uv_extract::hash::Hasher; use uv_fs::{Simplified, rename_with_retry, write_atomic}; -use uv_git::{GIT_LFS, GitError}; +use uv_git::{Fetch, GIT_LFS, GitError}; use uv_git_types::{GitHubRepository, GitOid}; use uv_metadata::read_archive_metadata; use uv_normalize::PackageName; @@ -70,6 +70,9 @@ pub(crate) const HTTP_REVISION: &str = "revision.http"; /// The name of the file that contains the revision ID for a local distribution, encoded via `MsgPack`. pub(crate) const LOCAL_REVISION: &str = "revision.rev"; +/// The name of the file that contains the cached distribution hashes, encoded via `MsgPack`. +pub(crate) const HASHES: &str = "hashes.msgpack"; + /// The name of the file that contains the cached distribution metadata, encoded via `MsgPack`. pub(crate) const METADATA: &str = "metadata.msgpack"; @@ -181,8 +184,19 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .boxed_local() .await? } - BuildableSource::Dist(SourceDist::Git(dist)) => { - self.git(source, &GitSourceUrl::from(dist), tags, hashes, client) + BuildableSource::Dist(SourceDist::GitDirectory(dist)) => { + self.git_source_tree( + source, + &GitDirectorySourceUrl::from(dist), + tags, + hashes, + client, + ) + .boxed_local() + .await? + } + BuildableSource::Dist(SourceDist::GitPath(dist)) => { + self.git_archive(source, &GitPathSourceUrl::from(dist), tags, hashes, client) .boxed_local() .await? } @@ -227,8 +241,13 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .boxed_local() .await? } - BuildableSource::Url(SourceUrl::Git(resource)) => { - self.git(source, resource, tags, hashes, client) + BuildableSource::Url(SourceUrl::GitDirectory(resource)) => { + self.git_source_tree(source, resource, tags, hashes, client) + .boxed_local() + .await? + } + BuildableSource::Url(SourceUrl::GitPath(resource)) => { + self.git_archive(source, resource, tags, hashes, client) .boxed_local() .await? } @@ -325,10 +344,10 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .boxed_local() .await? } - BuildableSource::Dist(SourceDist::Git(dist)) => { - self.git_metadata( + BuildableSource::Dist(SourceDist::GitDirectory(dist)) => { + self.git_source_tree_metadata( source, - &GitSourceUrl::from(dist), + &GitDirectorySourceUrl::from(dist), hashes, client, client.unmanaged.credentials_cache(), @@ -336,6 +355,11 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .boxed_local() .await? } + BuildableSource::Dist(SourceDist::GitPath(dist)) => { + self.git_archive_metadata(source, &GitPathSourceUrl::from(dist), hashes, client) + .boxed_local() + .await? + } BuildableSource::Dist(SourceDist::Directory(dist)) => { self.source_tree_metadata( source, @@ -375,8 +399,8 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .boxed_local() .await? } - BuildableSource::Url(SourceUrl::Git(resource)) => { - self.git_metadata( + BuildableSource::Url(SourceUrl::GitDirectory(resource)) => { + self.git_source_tree_metadata( source, resource, hashes, @@ -386,6 +410,11 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .boxed_local() .await? } + BuildableSource::Url(SourceUrl::GitPath(resource)) => { + self.git_archive_metadata(source, resource, hashes, client) + .boxed_local() + .await? + } BuildableSource::Url(SourceUrl::Directory(resource)) => { self.source_tree_metadata( source, @@ -1569,11 +1598,321 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { } } + /// Return the [`RevisionHashes`] for an archive stored in a Git repository. + async fn git_archive_revision( + &self, + source: &BuildableSource<'_>, + resource: &GitPathSourceUrl<'_>, + fetch: &Fetch, + cache_shard: &CacheShard, + hashes: HashPolicy<'_>, + ) -> Result { + // Verify that the archive exists. + let install_path = fetch.path().join(&resource.path); + if !install_path.is_file() { + return Err(Error::NotFound(resource.url.to_url())); + } + + // Read the existing metadata from the cache. + let revision_entry = cache_shard.entry(HASHES); + + // If the revision already exists, return it. There's no need to check for freshness, since + // everything is scoped to a Git commit. + if let Some(revision) = RevisionHashes::read_from(&revision_entry)? { + if revision.has_digests(hashes) { + return Ok(revision); + } + } + + // Otherwise, we need to unzip the archive, or at least compute the hashes. + debug!("Unpacking source distribution: {source}"); + let entry = cache_shard.entry(SOURCE); + let algorithms = hashes.algorithms(); + let hashes = self + .persist_archive(&install_path, resource.ext, entry.path(), &algorithms) + .await?; + + // Persist the revision. + let revision = RevisionHashes { hashes }; + revision.write_to(&revision_entry).await?; + + Ok(revision) + } + + /// Build a source distribution from a Git repository. + async fn git_archive( + &self, + source: &BuildableSource<'_>, + resource: &GitPathSourceUrl<'_>, + tags: &Tags, + hashes: HashPolicy<'_>, + client: &ManagedClient<'_>, + ) -> Result { + // Fetch the Git repository. + let fetch = self + .build_context + .git() + .fetch( + resource.git, + client.unmanaged.disable_ssl(resource.git.url()), + client.unmanaged.connectivity() == Connectivity::Offline, + self.build_context.cache().bucket(CacheBucket::Git), + self.reporter + .clone() + .map(|reporter| reporter.into_git_reporter()), + ) + .await?; + + let git_sha = fetch.git().precise().expect("Exact commit after checkout"); + let cache_shard = self.build_context.cache().shard( + CacheBucket::SourceDistributions, + WheelCache::Git(resource.url, git_sha.as_short_str()).root(), + ); + + // Fetch the revision for the source distribution. + let revision = self + .git_archive_revision(source, resource, &fetch, &cache_shard, hashes) + .await?; + + // Before running the build, check that the hashes match. + if !revision.satisfies(hashes) { + return Err(Error::hash_mismatch( + source.to_string(), + hashes.digests(), + revision.hashes(), + )); + } + + let source_entry = cache_shard.entry(SOURCE); + + // If there are build settings or extra build dependencies, we need to scope to a cache shard. + let config_settings = self.config_settings_for(source.name()); + let extra_build_deps = self.extra_build_dependencies_for(source.name()); + let extra_build_variables = self.extra_build_variables_for(source.name()); + let build_info = + BuildInfo::from_settings(&config_settings, extra_build_deps, extra_build_variables); + let cache_shard = build_info + .cache_shard() + .map(|digest| cache_shard.shard(digest)) + .unwrap_or(cache_shard); + + // If the cache contains a compatible wheel, return it. + if let Some(file) = BuiltWheelFile::find_in_cache(tags, &cache_shard) + .ok() + .flatten() + .filter(|file| file.matches(source.name(), source.version())) + { + return Ok(BuiltWheelMetadata::from_file( + file, + revision.into_hashes(), + CacheInfo::default(), + build_info, + )); + } + + // Otherwise, we need to build a wheel. + let task = self + .reporter + .as_ref() + .map(|reporter| reporter.on_build_start(source)); + + let (disk_filename, filename, metadata) = self + .build_distribution( + source, + source_entry.path(), + None, + &cache_shard, + NoSources::None, + ) + .await?; + + if let Some(task) = task { + if let Some(reporter) = self.reporter.as_ref() { + reporter.on_build_complete(source, task); + } + } + + // Store the metadata. + let metadata_entry = cache_shard.entry(METADATA); + write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?) + .await + .map_err(Error::CacheWrite)?; + + Ok(BuiltWheelMetadata { + path: cache_shard.join(&disk_filename).into_boxed_path(), + target: cache_shard.join(filename.stem()).into_boxed_path(), + filename, + hashes: revision.into_hashes(), + cache_info: CacheInfo::default(), + build_info, + }) + } + + /// Build a source distribution from a Git repository. + async fn git_archive_metadata( + &self, + source: &BuildableSource<'_>, + resource: &GitPathSourceUrl<'_>, + hashes: HashPolicy<'_>, + client: &ManagedClient<'_>, + ) -> Result { + // Fetch the Git repository. + let fetch = self + .build_context + .git() + .fetch( + resource.git, + client.unmanaged.disable_ssl(resource.git.url()), + client.unmanaged.connectivity() == Connectivity::Offline, + self.build_context.cache().bucket(CacheBucket::Git), + self.reporter + .clone() + .map(|reporter| reporter.into_git_reporter()), + ) + .await?; + + let git_sha = fetch.git().precise().expect("Exact commit after checkout"); + let cache_shard = self.build_context.cache().shard( + CacheBucket::SourceDistributions, + WheelCache::Git(resource.url, git_sha.as_short_str()).root(), + ); + + // Fetch the revision for the source distribution. + let revision = self + .git_archive_revision(source, resource, &fetch, &cache_shard, hashes) + .await?; + + // Before running the build, check that the hashes match. + if !revision.satisfies(hashes) { + return Err(Error::hash_mismatch( + source.to_string(), + hashes.digests(), + revision.hashes(), + )); + } + + let source_entry = cache_shard.entry(SOURCE); + + // If the metadata is static, return it. + let dynamic = match StaticMetadata::read(source, source_entry.path(), None).await? { + StaticMetadata::Some(metadata) => { + return Ok(ArchiveMetadata { + metadata: Metadata::from_metadata23(metadata), + hashes: revision.into_hashes(), + }); + } + StaticMetadata::Dynamic => true, + StaticMetadata::None => false, + }; + + // If the cache contains compatible metadata, return it. + let metadata_entry = cache_shard.entry(METADATA); + match CachedMetadata::read(&metadata_entry).await { + Ok(Some(metadata)) => { + if metadata.matches(source.name(), source.version()) { + debug!("Using cached metadata for: {source}"); + return Ok(ArchiveMetadata { + metadata: Metadata::from_metadata23(metadata.into()), + hashes: revision.into_hashes(), + }); + } + debug!("Cached metadata does not match expected name and version for: {source}"); + } + Ok(None) => {} + Err(err) => { + debug!("Failed to deserialize cached metadata for: {source} ({err})"); + } + } + + // If the backend supports `prepare_metadata_for_build_wheel`, use it. + if let Some(metadata) = self + .build_metadata(source, source_entry.path(), None, NoSources::None) + .boxed_local() + .await? + { + // If necessary, mark the metadata as dynamic. + let metadata = if dynamic { + ResolutionMetadata { + dynamic: true, + ..metadata + } + } else { + metadata + }; + + // Store the metadata. + fs::create_dir_all(metadata_entry.dir()) + .await + .map_err(Error::CacheWrite)?; + write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?) + .await + .map_err(Error::CacheWrite)?; + + return Ok(ArchiveMetadata { + metadata: Metadata::from_metadata23(metadata), + hashes: revision.into_hashes(), + }); + } + + // If there are build settings or extra build dependencies, we need to scope to a cache shard. + let config_settings = self.config_settings_for(source.name()); + let extra_build_deps = self.extra_build_dependencies_for(source.name()); + let extra_build_variables = self.extra_build_variables_for(source.name()); + let build_info = + BuildInfo::from_settings(&config_settings, extra_build_deps, extra_build_variables); + let cache_shard = build_info + .cache_shard() + .map(|digest| cache_shard.shard(digest)) + .unwrap_or(cache_shard); + + // Otherwise, we need to build a wheel. + let task = self + .reporter + .as_ref() + .map(|reporter| reporter.on_build_start(source)); + + let (_disk_filename, _filename, metadata) = self + .build_distribution( + source, + source_entry.path(), + None, + &cache_shard, + NoSources::None, + ) + .await?; + + if let Some(task) = task { + if let Some(reporter) = self.reporter.as_ref() { + reporter.on_build_complete(source, task); + } + } + + // If necessary, mark the metadata as dynamic. + let metadata = if dynamic { + ResolutionMetadata { + dynamic: true, + ..metadata + } + } else { + metadata + }; + + // Store the metadata. + write_atomic(metadata_entry.path(), rmp_serde::to_vec(&metadata)?) + .await + .map_err(Error::CacheWrite)?; + + Ok(ArchiveMetadata { + metadata: Metadata::from_metadata23(metadata), + hashes: revision.into_hashes(), + }) + } + /// Build a source distribution from a Git repository. - async fn git( + async fn git_source_tree( &self, source: &BuildableSource<'_>, - resource: &GitSourceUrl<'_>, + resource: &GitDirectorySourceUrl<'_>, tags: &Tags, hashes: HashPolicy<'_>, client: &ManagedClient<'_>, @@ -1702,10 +2041,10 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { /// /// If the build backend supports `prepare_metadata_for_build_wheel`, this method will avoid /// building the wheel. - async fn git_metadata( + async fn git_source_tree_metadata( &self, source: &BuildableSource<'_>, - resource: &GitSourceUrl<'_>, + resource: &GitDirectorySourceUrl<'_>, hashes: HashPolicy<'_>, client: &ManagedClient<'_>, credentials_cache: &CredentialsCache, @@ -2044,8 +2383,10 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { client: &ManagedClient<'_>, ) -> Result, Error> { let git = match source { - BuildableSource::Dist(SourceDist::Git(source)) => &*source.git, - BuildableSource::Url(SourceUrl::Git(source)) => source.git, + BuildableSource::Dist(SourceDist::GitDirectory(source)) => &*source.git, + BuildableSource::Dist(SourceDist::GitPath(source)) => &*source.git, + BuildableSource::Url(SourceUrl::GitDirectory(source)) => source.git, + BuildableSource::Url(SourceUrl::GitPath(source)) => source.git, _ => { return Ok(None); } @@ -2096,10 +2437,10 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { &self, commit: GitOid, source: &BuildableSource<'_>, - resource: &GitSourceUrl<'_>, + resource: &GitDirectorySourceUrl<'_>, client: &ManagedClient<'_>, ) -> Result, Error> { - let GitSourceUrl { + let GitDirectorySourceUrl { git, subdirectory, .. } = resource; @@ -2439,7 +2780,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .map_err(Error::CacheWrite)?; if let Err(err) = rename_with_retry(extracted, target).await { // If the directory already exists, accept it. - if err.kind() == std::io::ErrorKind::AlreadyExists { + if err.kind() == std::io::ErrorKind::DirectoryNotEmpty { warn!("Directory already exists: {}", target.display()); } else { return Err(Error::CacheWrite(err)); @@ -3076,6 +3417,46 @@ impl LocalRevisionPointer { } } +/// A pointer to a source distribution revision in the cache, fetched from a local path. +/// +/// Encoded with `MsgPack`, and represented on disk by a `.rev` file. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct RevisionHashes { + hashes: Vec, +} + +impl RevisionHashes { + /// Read an [`RevisionHashes`] from the cache. + pub(crate) fn read_from(path: impl AsRef) -> Result, Error> { + match fs_err::read(path) { + Ok(cached) => Ok(Some(rmp_serde::from_slice::(&cached)?)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(Error::CacheRead(err)), + } + } + + /// Write an [`LocalRevisionPointer`] to the cache. + async fn write_to(&self, entry: &CacheEntry) -> Result<(), Error> { + fs::create_dir_all(&entry.dir()) + .await + .map_err(Error::CacheWrite)?; + write_atomic(entry.path(), rmp_serde::to_vec(&self)?) + .await + .map_err(Error::CacheWrite) + } + + /// Return the computed hashes of the archive. + pub(crate) fn into_hashes(self) -> HashDigests { + HashDigests::from(self.hashes) + } +} + +impl Hashed for RevisionHashes { + fn hashes(&self) -> &[HashDigest] { + &self.hashes + } +} + /// Read the [`ResolutionMetadata`] from a source distribution's `PKG-INFO` file, if it uses Metadata 2.2 /// or later _and_ none of the required fields (`Requires-Python`, `Requires-Dist`, and /// `Provides-Extra`) are marked as dynamic. diff --git a/crates/uv-installer/src/plan.rs b/crates/uv-installer/src/plan.rs index d03981a5a54c7..a6e5e94feac8b 100644 --- a/crates/uv-installer/src/plan.rs +++ b/crates/uv-installer/src/plan.rs @@ -10,7 +10,7 @@ use uv_cache::{Cache, CacheBucket, WheelCache}; use uv_cache_info::Timestamp; use uv_configuration::{BuildOptions, Reinstall}; use uv_distribution::{ - BuiltWheelIndex, HttpArchivePointer, LocalArchivePointer, RegistryWheelIndex, + BuiltWheelIndex, HttpArchivePointer, PathArchivePointer, RegistryWheelIndex, }; use uv_distribution_filename::WheelFilename; use uv_distribution_types::{ @@ -515,7 +515,7 @@ impl<'a> Planner<'a> { ) .entry(format!("{}.rev", wheel.filename.cache_key())); - match LocalArchivePointer::read_from(&cache_entry) { + match PathArchivePointer::read_from(&cache_entry) { Ok(Some(pointer)) => match Timestamp::from_path(&wheel.install_path) { Ok(timestamp) => { if pointer.is_up_to_date(timestamp) { @@ -534,7 +534,6 @@ impl<'a> Planner<'a> { build_info, path: cache.archive(&archive.id).into_boxed_path(), }; - debug!( "Path wheel requirement already cached: {cached_dist}" ); @@ -558,6 +557,55 @@ impl<'a> Planner<'a> { } } } + Dist::Built(BuiltDist::GitPath(wheel)) => { + if !wheel.filename.is_compatible(tags) { + bail!( + "A Git path dependency is incompatible with the current platform: {}", + wheel.install_path.user_display() + ); + } + + if no_binary { + bail!( + "A Git path dependency points to a wheel which conflicts with `--no-binary`: {}", + wheel.url + ); + } + + if let Some(git_sha) = wheel.git.precise() { + // Find the exact wheel from the cache, since we know the filename in + // advance. + let cache_entry = cache + .shard( + CacheBucket::Wheels, + WheelCache::Git(&wheel.url, git_sha.as_short_str()).root(), + ) + .entry(format!("{}.rev", wheel.filename.cache_key())); + + if let Some(pointer) = PathArchivePointer::read_from(&cache_entry)? { + let cache_info = pointer.to_cache_info(); + let build_info = pointer.to_build_info(); + let archive = pointer.into_archive(); + if archive.satisfies(hasher.get(dist.as_ref())) { + let cached_dist = CachedDirectUrlDist { + filename: wheel.filename.clone(), + url: VerbatimParsedUrl { + parsed_url: wheel.parsed_url(), + verbatim: wheel.url.clone(), + }, + hashes: archive.hashes, + cache_info, + build_info, + path: cache.archive(&archive.id).into_boxed_path(), + }; + + debug!("Git wheel requirement already cached: {cached_dist}"); + cached.push(CachedDist::Url(cached_dist)); + continue; + } + } + } + } Dist::Source(SourceDist::Registry(sdist)) => { if let Some(distribution) = registry_index.get(sdist.name()).find_map(|entry| { if *entry.index().url() != sdist.index { @@ -608,10 +656,28 @@ impl<'a> Planner<'a> { } } } - Dist::Source(SourceDist::Git(sdist)) => { + Dist::Source(SourceDist::GitPath(sdist)) => { + // Find the most-compatible wheel from the cache, since we don't know + // the filename in advance. + if let Some(wheel) = built_index.git_path(sdist)? { + if wheel.filename().name == sdist.name { + let cached_dist = wheel.into_git_path_dist(sdist); + debug!("Git source requirement already cached: {cached_dist}"); + cached.push(CachedDist::Url(cached_dist)); + continue; + } + + warn!( + "Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)", + sdist, + wheel.filename() + ); + } + } + Dist::Source(SourceDist::GitDirectory(sdist)) => { // Find the most-compatible wheel from the cache, since we don't know // the filename in advance. - if let Some(wheel) = built_index.git(sdist) { + if let Some(wheel) = built_index.git_directory(sdist) { if wheel.filename().name == sdist.name { let cached_dist = wheel.into_git_dist(sdist); debug!("Git source requirement already cached: {cached_dist}"); diff --git a/crates/uv-installer/src/satisfies.rs b/crates/uv-installer/src/satisfies.rs index 0f1a32090dda3..31500adc84bba 100644 --- a/crates/uv-installer/src/satisfies.rs +++ b/crates/uv-installer/src/satisfies.rs @@ -158,7 +158,7 @@ impl RequirementSatisfaction { } } } - RequirementSource::Git { + RequirementSource::GitDirectory { url: _, git: requested_git, subdirectory: requested_subdirectory, @@ -178,6 +178,7 @@ impl RequirementSatisfaction { git_lfs: installed_git_lfs, }, subdirectory: installed_subdirectory, + path: None, } = direct_url.as_ref() else { return Self::Mismatch; @@ -225,6 +226,73 @@ impl RequirementSatisfaction { return Self::OutOfDate; } } + RequirementSource::GitPath { + url: _, + git: requested_git, + install_path: requested_path, + ext: _, + } => { + let InstalledDistKind::Url(InstalledDirectUrlDist { direct_url, .. }) = + &distribution.kind + else { + return Self::Mismatch; + }; + let DirectUrl::VcsUrl { + url: installed_url, + vcs_info: + VcsInfo { + vcs: VcsKind::Git, + requested_revision: _, + commit_id: installed_precise, + git_lfs: installed_git_lfs, + }, + subdirectory: None, + path: Some(installed_path), + } = direct_url.as_ref() + else { + return Self::Mismatch; + }; + + if requested_path != installed_path { + debug!( + "Path mismatch: {:?} vs. {:?}", + installed_path, requested_path + ); + return Self::Mismatch; + } + + let requested_git_lfs = requested_git.lfs(); + let installed_git_lfs = installed_git_lfs.map(GitLfs::from).unwrap_or_default(); + if requested_git_lfs != installed_git_lfs { + debug!( + "Git LFS mismatch: {} (installed) vs. {} (requested)", + installed_git_lfs, requested_git_lfs, + ); + return Self::Mismatch; + } + + if !RepositoryUrl::parse(installed_url) + .is_ok_and(|installed_url| installed_url == *requested_git.repository()) + { + debug!( + "Repository mismatch: {:?} vs. {:?}", + installed_url, + requested_git.url() + ); + return Self::Mismatch; + } + + if installed_precise.as_deref() + != requested_git.precise().as_ref().map(GitOid::as_str) + { + debug!( + "Precise mismatch: {:?} vs. {:?}", + installed_precise, + requested_git.precise() + ); + return Self::OutOfDate; + } + } RequirementSource::Path { install_path: requested_path, ext: _, diff --git a/crates/uv-pypi-types/src/direct_url.rs b/crates/uv-pypi-types/src/direct_url.rs index 81845cb53f8e2..9363be6f27e61 100644 --- a/crates/uv-pypi-types/src/direct_url.rs +++ b/crates/uv-pypi-types/src/direct_url.rs @@ -1,5 +1,5 @@ use std::collections::BTreeMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError}; @@ -43,6 +43,8 @@ pub enum DirectUrl { vcs_info: VcsInfo, #[serde(skip_serializing_if = "Option::is_none")] subdirectory: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + path: Option, }, } @@ -125,6 +127,7 @@ impl TryFrom<&DirectUrl> for DisplaySafeUrl { url, vcs_info, subdirectory, + path, } => { let mut url = Self::parse(&format!("{}+{}", vcs_info.vcs, url))?; if let Some(commit_id) = &vcs_info.commit_id { @@ -142,6 +145,9 @@ impl TryFrom<&DirectUrl> for DisplaySafeUrl { if let Some(true) = vcs_info.git_lfs { frags.push("lfs=true".to_string()); } + if let Some(path) = path { + frags.push(format!("path={}", path.display())); + } if !frags.is_empty() { url.set_fragment(Some(&frags.join("&"))); } diff --git a/crates/uv-pypi-types/src/parsed_url.rs b/crates/uv-pypi-types/src/parsed_url.rs index 04b5926af4303..7872811efac3f 100644 --- a/crates/uv-pypi-types/src/parsed_url.rs +++ b/crates/uv-pypi-types/src/parsed_url.rs @@ -182,8 +182,10 @@ pub enum ParsedUrl { Path(ParsedPathUrl), /// The direct URL is a path to a local directory. Directory(ParsedDirectoryUrl), - /// The direct URL is path to a Git repository. - Git(ParsedGitUrl), + /// The direct URL is path to a source tree within a Git repository. + GitDirectory(ParsedGitDirectoryUrl), + /// The direct URL is path to an archive within a Git repository. + GitPath(ParsedGitPathUrl), /// The direct URL is a URL to a source archive (e.g., a `.tar.gz` file) or built archive /// (i.e., a `.whl` file). Archive(ParsedArchiveUrl), @@ -259,7 +261,7 @@ impl ParsedDirectoryUrl { } } -/// A Git repository URL. +/// A Git repository URL, pointing to the repository root or a subdirectory defining a source tree. /// /// Explicit `lfs = true` or `--lfs` should be used to enable Git LFS support as /// we do not support implicit parsing of the `lfs=true` url fragments for now. @@ -268,19 +270,19 @@ impl ParsedDirectoryUrl { /// * `git+https://git.example.com/MyProject.git` /// * `git+https://git.example.com/MyProject.git@v1.0#egg=pkg&subdirectory=pkg_dir` #[derive(Debug, Clone, Eq, PartialEq, PartialOrd, Hash, Ord)] -pub struct ParsedGitUrl { +pub struct ParsedGitDirectoryUrl { pub url: GitUrl, pub subdirectory: Option>, } -impl ParsedGitUrl { - /// Construct a [`ParsedGitUrl`] from a Git requirement source. +impl ParsedGitDirectoryUrl { + /// Construct a [`ParsedGitDirectoryUrl`] from a Git requirement source. pub fn from_source(url: GitUrl, subdirectory: Option>) -> Self { Self { url, subdirectory } } } -impl TryFrom for ParsedGitUrl { +impl TryFrom for ParsedGitDirectoryUrl { type Error = ParsedUrlError; /// Supports URLs with and without the `git+` prefix. @@ -301,6 +303,57 @@ impl TryFrom for ParsedGitUrl { } } +/// A Git repository URL, pointing to a pre-built archive within the repository. +/// +/// Examples: +/// * `git+https://git.example.com/MyProject.git@v1.0#egg=pkg&path=path/to/wheel.whl` +#[derive(Debug, Clone, Eq, PartialEq, PartialOrd, Hash, Ord)] +pub struct ParsedGitPathUrl { + pub url: GitUrl, + /// The path to the distribution within the repository. + pub install_path: PathBuf, + /// The file extension, e.g. `tar.gz`, `zip`, etc. + pub ext: DistExtension, +} + +impl ParsedGitPathUrl { + /// Construct a [`ParsedGitPathUrl`] from a Git requirement source. + pub fn from_source(url: GitUrl, install_path: PathBuf, ext: DistExtension) -> Self { + Self { + url, + install_path, + ext, + } + } +} + +impl TryFrom for ParsedGitPathUrl { + type Error = ParsedUrlError; + + /// Supports URLs with and without the `git+` prefix. + /// + /// When the URL includes a prefix, it's presumed to come from a PEP 508 requirement; when it's + /// excluded, it's presumed to come from `tool.uv.sources`. + fn try_from(url_in: DisplaySafeUrl) -> Result { + let install_path = get_install_path(&url_in).unwrap(); + let ext = DistExtension::from_path(&install_path) + .map_err(|err| ParsedUrlError::MissingExtensionPath(install_path.clone(), err))?; + + let url = url_in + .as_str() + .strip_prefix("git+") + .unwrap_or(url_in.as_str()); + let url = DisplaySafeUrl::parse(url) + .map_err(|err| ParsedUrlError::UrlParse(url.to_string(), err))?; + let url = GitUrl::try_from(url)?; + Ok(Self { + url, + install_path, + ext, + }) + } +} + /// A URL to a source or built archive. /// /// Examples: @@ -354,10 +407,10 @@ impl TryFrom for ParsedArchiveUrl { } } -/// If the URL points to a subdirectory, extract it, as in (git): +/// If the URL points to a subdirectory, extract it, as in (Git): /// `git+https://git.example.com/MyProject.git@v1.0#subdirectory=pkg_dir` /// `git+https://git.example.com/MyProject.git@v1.0#egg=pkg&subdirectory=pkg_dir` -/// or (direct archive url): +/// or (direct URL): /// `https://github.com/foo-labs/foo/archive/master.zip#subdirectory=packages/bar` /// `https://github.com/foo-labs/foo/archive/master.zip#egg=pkg&subdirectory=packages/bar` fn get_subdirectory(url: &Url) -> Option { @@ -368,13 +421,30 @@ fn get_subdirectory(url: &Url) -> Option { Some(PathBuf::from(subdirectory)) } +/// If the URL points to an archive, extract it, as in (Git): +/// `git+https://git.example.com/MyProject.git@v1.0#path=path/to/wheel.whl` +/// `git+https://git.example.com/MyProject.git@v1.0#egg=pkg&path=path/to/wheel.whl` +fn get_install_path(url: &Url) -> Option { + let fragment = url.fragment()?; + let install_path = fragment + .split('&') + .find_map(|fragment| fragment.strip_prefix("path="))?; + Some(PathBuf::from(install_path)) +} + impl TryFrom for ParsedUrl { type Error = ParsedUrlError; fn try_from(url: DisplaySafeUrl) -> Result { if let Some((prefix, ..)) = url.scheme().split_once('+') { match prefix { - "git" => Ok(Self::Git(ParsedGitUrl::try_from(url)?)), + "git" => { + if get_install_path(&url).is_some() { + Ok(Self::GitPath(ParsedGitPathUrl::try_from(url)?)) + } else { + Ok(Self::GitDirectory(ParsedGitDirectoryUrl::try_from(url)?)) + } + } "bzr" => Err(ParsedUrlError::UnsupportedUrlPrefix { prefix: prefix.to_string(), url: url.to_string(), @@ -400,7 +470,7 @@ impl TryFrom for ParsedUrl { .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("git")) { - Ok(Self::Git(ParsedGitUrl::try_from(url)?)) + Ok(Self::GitDirectory(ParsedGitDirectoryUrl::try_from(url)?)) } else if url.scheme().eq_ignore_ascii_case("file") { let path = url .to_file_path() @@ -436,7 +506,8 @@ impl From<&ParsedUrl> for DirectUrl { match value { ParsedUrl::Path(value) => Self::from(value), ParsedUrl::Directory(value) => Self::from(value), - ParsedUrl::Git(value) => Self::from(value), + ParsedUrl::GitPath(value) => Self::from(value), + ParsedUrl::GitDirectory(value) => Self::from(value), ParsedUrl::Archive(value) => Self::from(value), } } @@ -480,10 +551,10 @@ impl From<&ParsedArchiveUrl> for DirectUrl { } } -impl From<&ParsedGitUrl> for DirectUrl { - fn from(value: &ParsedGitUrl) -> Self { +impl From<&ParsedGitDirectoryUrl> for DirectUrl { + fn from(value: &ParsedGitDirectoryUrl) -> Self { Self::VcsUrl { - url: value.url.url().to_string(), + url: value.url.repository().to_string(), vcs_info: VcsInfo { vcs: VcsKind::Git, commit_id: value.url.precise().as_ref().map(ToString::to_string), @@ -491,6 +562,23 @@ impl From<&ParsedGitUrl> for DirectUrl { git_lfs: value.url.lfs().enabled().then_some(true), }, subdirectory: value.subdirectory.clone(), + path: None, + } + } +} + +impl From<&ParsedGitPathUrl> for DirectUrl { + fn from(value: &ParsedGitPathUrl) -> Self { + Self::VcsUrl { + url: value.url.repository().to_string(), + vcs_info: VcsInfo { + vcs: VcsKind::Git, + commit_id: value.url.precise().as_ref().map(ToString::to_string), + requested_revision: value.url.reference().as_str().map(ToString::to_string), + git_lfs: value.url.lfs().enabled().then_some(true), + }, + subdirectory: None, + path: Some(value.install_path.clone()), } } } @@ -500,7 +588,8 @@ impl From for DisplaySafeUrl { match value { ParsedUrl::Path(value) => value.into(), ParsedUrl::Directory(value) => value.into(), - ParsedUrl::Git(value) => value.into(), + ParsedUrl::GitPath(value) => value.into(), + ParsedUrl::GitDirectory(value) => value.into(), ParsedUrl::Archive(value) => value.into(), } } @@ -528,8 +617,22 @@ impl From for DisplaySafeUrl { } } -impl From for DisplaySafeUrl { - fn from(value: ParsedGitUrl) -> Self { +impl From for DisplaySafeUrl { + fn from(value: ParsedGitPathUrl) -> Self { + let lfs = value.url.lfs().enabled(); + let mut url = Self::parse(&format!("{}{}", "git+", Self::from(value.url).as_str())) + .expect("Git URL is invalid"); + let mut frags = vec![format!("path={}", value.install_path.display())]; + if lfs { + frags.push("lfs=true".to_string()); + } + url.set_fragment(Some(&frags.join("&"))); + url + } +} + +impl From for DisplaySafeUrl { + fn from(value: ParsedGitDirectoryUrl) -> Self { let lfs = value.url.lfs().enabled(); let mut url = Self::parse(&format!("{}{}", "git+", Self::from(value.url).as_str())) .expect("Git URL is invalid"); diff --git a/crates/uv-requirements-txt/src/requirement.rs b/crates/uv-requirements-txt/src/requirement.rs index 849f8232784c7..dc9523017569c 100644 --- a/crates/uv-requirements-txt/src/requirement.rs +++ b/crates/uv-requirements-txt/src/requirement.rs @@ -82,7 +82,10 @@ impl RequirementsTxtRequirement { ParsedUrl::Archive(_) => { return Err(EditableError::Https(requirement.name, url.to_string())); } - ParsedUrl::Git(_) => { + ParsedUrl::GitDirectory(_) => { + return Err(EditableError::Git(requirement.name, url.to_string())); + } + ParsedUrl::GitPath(_) => { return Err(EditableError::Git(requirement.name, url.to_string())); } }; @@ -107,7 +110,10 @@ impl RequirementsTxtRequirement { ParsedUrl::Archive(_) => { return Err(EditableError::UnnamedHttps(requirement.to_string())); } - ParsedUrl::Git(_) => { + ParsedUrl::GitDirectory(_) => { + return Err(EditableError::UnnamedGit(requirement.to_string())); + } + ParsedUrl::GitPath(_) => { return Err(EditableError::UnnamedGit(requirement.to_string())); } }; diff --git a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__line-endings-whitespace.txt.snap b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__line-endings-whitespace.txt.snap index 7904a49eaee06..5084362ed87bb 100644 --- a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__line-endings-whitespace.txt.snap +++ b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__line-endings-whitespace.txt.snap @@ -37,8 +37,8 @@ RequirementsTxt { version_or_url: Some( Url( VerbatimParsedUrl { - parsed_url: Git( - ParsedGitUrl { + parsed_url: GitDirectory( + ParsedGitDirectoryUrl { url: GitUrl { url: DisplaySafeUrl { scheme: "https", diff --git a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-whitespace.txt.snap b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-whitespace.txt.snap index 8afdb9a07dea1..510c64213ea8d 100644 --- a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-whitespace.txt.snap +++ b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-whitespace.txt.snap @@ -37,8 +37,8 @@ RequirementsTxt { version_or_url: Some( Url( VerbatimParsedUrl { - parsed_url: Git( - ParsedGitUrl { + parsed_url: GitDirectory( + ParsedGitDirectoryUrl { url: GitUrl { url: DisplaySafeUrl { scheme: "https", diff --git a/crates/uv-requirements/src/lib.rs b/crates/uv-requirements/src/lib.rs index 4ca401a12c0e9..53eb12a4343fc 100644 --- a/crates/uv-requirements/src/lib.rs +++ b/crates/uv-requirements/src/lib.rs @@ -9,9 +9,7 @@ pub use crate::upgrade::{ read_requirements_txt, }; -use uv_distribution_types::{ - Dist, DistErrorKind, GitSourceDist, Requirement, RequirementSource, SourceDist, -}; +use uv_distribution_types::{Dist, DistErrorKind, Requirement, RequirementSource}; mod extras; mod lookahead; @@ -75,16 +73,28 @@ pub(crate) fn required_dist( subdirectory.clone(), *ext, )?, - RequirementSource::Git { + RequirementSource::GitDirectory { git, subdirectory, url, - } => Dist::Source(SourceDist::Git(GitSourceDist { - name: requirement.name.clone(), - git: Box::new(git.clone()), - subdirectory: subdirectory.clone(), - url: url.clone(), - })), + } => Dist::from_git_directory_url( + requirement.name.clone(), + url.clone(), + git.clone(), + subdirectory.clone(), + )?, + RequirementSource::GitPath { + git, + install_path, + ext, + url, + } => Dist::from_git_path_url( + requirement.name.clone(), + url.clone(), + git.clone(), + install_path.clone(), + *ext, + )?, RequirementSource::Path { install_path, ext, diff --git a/crates/uv-requirements/src/unnamed.rs b/crates/uv-requirements/src/unnamed.rs index 6c21f2e7cc39d..7ef6bbfcc6a01 100644 --- a/crates/uv-requirements/src/unnamed.rs +++ b/crates/uv-requirements/src/unnamed.rs @@ -11,8 +11,8 @@ use url::Host; use uv_distribution::{DistributionDatabase, Reporter}; use uv_distribution_filename::{DistExtension, SourceDistFilename, WheelFilename}; use uv_distribution_types::{ - BuildableSource, DirectSourceUrl, DirectorySourceUrl, GitSourceUrl, Identifier, PathSourceUrl, - RemoteSource, Requirement, SourceUrl, + BuildableSource, DirectSourceUrl, DirectorySourceUrl, GitDirectorySourceUrl, GitPathSourceUrl, + Identifier, PathSourceUrl, RemoteSource, Requirement, SourceUrl, }; use uv_fs::Simplified; use uv_normalize::PackageName; @@ -268,11 +268,25 @@ impl<'a, Context: BuildContext> NamedRequirementsResolver<'a, Context> { ext, }) } - ParsedUrl::Git(parsed_git_url) => SourceUrl::Git(GitSourceUrl { - url: &requirement.url.verbatim, - git: &parsed_git_url.url, - subdirectory: parsed_git_url.subdirectory.as_deref(), - }), + ParsedUrl::GitDirectory(parsed_git_url) => { + SourceUrl::GitDirectory(GitDirectorySourceUrl { + url: &requirement.url.verbatim, + git: &parsed_git_url.url, + subdirectory: parsed_git_url.subdirectory.as_deref(), + }) + } + ParsedUrl::GitPath(parsed_git_url) => { + let ext = match parsed_git_url.ext { + DistExtension::Source(ext) => ext, + DistExtension::Wheel => unreachable!(), + }; + SourceUrl::GitPath(GitPathSourceUrl { + url: &requirement.url.verbatim, + git: &parsed_git_url.url, + path: Cow::Borrowed(&parsed_git_url.install_path), + ext, + }) + } }; // Fetch the metadata for the distribution. diff --git a/crates/uv-resolver/src/lock/export/pylock_toml.rs b/crates/uv-resolver/src/lock/export/pylock_toml.rs index ebfae058fc16f..926b935f5e32d 100644 --- a/crates/uv-resolver/src/lock/export/pylock_toml.rs +++ b/crates/uv-resolver/src/lock/export/pylock_toml.rs @@ -23,7 +23,7 @@ use uv_distribution_filename::{ }; use uv_distribution_types::{ BuiltDist, DirectUrlBuiltDist, DirectUrlSourceDist, DirectorySourceDist, Dist, Edge, - FileLocation, GitSourceDist, IndexUrl, Name, Node, PathBuiltDist, PathSourceDist, + FileLocation, GitDirectorySourceDist, IndexUrl, Name, Node, PathBuiltDist, PathSourceDist, RegistryBuiltDist, RegistryBuiltWheel, RegistrySourceDist, RemoteSource, RequiresPython, Resolution, ResolvedDist, SourceDist, ToUrlError, UrlString, }; @@ -34,7 +34,7 @@ use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::Version; use uv_pep508::{MarkerEnvironment, MarkerTree, VerbatimUrl}; use uv_platform_tags::{TagCompatibility, TagPriority, Tags}; -use uv_pypi_types::{HashDigests, Hashes, ParsedGitUrl, VcsKind}; +use uv_pypi_types::{HashDigests, Hashes, ParsedGitDirectoryUrl, VcsKind}; use uv_redacted::DisplaySafeUrl; use uv_small_str::SmallString; @@ -85,6 +85,8 @@ pub enum PylockTomlErrorKind { "Package `{0}` must include one of: `wheels`, `directory`, `archive`, `sdist`, or `vcs`" )] MissingSource(PackageName), + #[error("Package `{0}` uses a Git archive, which pylock.toml export does not support")] + GitArchiveUnsupported(PackageName), #[error("Package `{0}` does not include a compatible wheel for the current platform")] MissingWheel(PackageName), #[error("`packages.wheel` entry for `{0}` must have a `path` or `url`")] @@ -433,6 +435,9 @@ impl<'lock> PylockToml { hashes: Hashes::from(node.hashes.clone()), }); } + Dist::Built(BuiltDist::GitPath(_)) => { + return Err(PylockTomlErrorKind::GitArchiveUnsupported(package.name)); + } Dist::Built(BuiltDist::Registry(dist)) => { package.wheels = Self::filter_and_convert_wheels( resolution, @@ -500,7 +505,7 @@ impl<'lock> PylockToml { subdirectory: None, }); } - Dist::Source(SourceDist::Git(dist)) => { + Dist::Source(SourceDist::GitDirectory(dist)) => { package.vcs = Some(PylockTomlVcs { r#type: VcsKind::Git, url: Some(dist.git.url().clone()), @@ -512,6 +517,9 @@ impl<'lock> PylockToml { subdirectory: dist.subdirectory.clone().map(PortablePathBuf::from), }); } + Dist::Source(SourceDist::GitPath(_)) => { + return Err(PylockTomlErrorKind::GitArchiveUnsupported(package.name)); + } Dist::Source(SourceDist::Path(dist)) => { let path = try_relative_to_if( &dist.install_path, @@ -798,7 +806,7 @@ impl<'lock> PylockToml { // Extract the `packages.vcs` field. let vcs = match &sdist { - Some(SourceDist::Git(sdist)) => Some(PylockTomlVcs { + Some(SourceDist::GitDirectory(sdist)) => Some(PylockTomlVcs { r#type: VcsKind::Git, url: Some(sdist.git.url().clone()), path: None, @@ -1150,7 +1158,7 @@ impl<'lock> PylockToml { } } else if let Some(sdist) = package.vcs.as_ref().filter(|_| !no_build) { let hashes = HashDigests::empty(); - let sdist = Dist::Source(SourceDist::Git( + let sdist = Dist::Source(SourceDist::GitDirectory( sdist.to_sdist(install_path, &package.name)?, )); let dist = ResolvedDist::Installable { @@ -1460,12 +1468,12 @@ impl PylockTomlDirectory { } impl PylockTomlVcs { - /// Convert the sdist to a [`GitSourceDist`]. + /// Convert the sdist to a [`GitDirectorySourceDist`]. fn to_sdist( &self, install_path: &Path, name: &PackageName, - ) -> Result { + ) -> Result { let subdirectory = self.subdirectory.clone().map(Box::::from); // Reconstruct the `GitUrl` from the individual fields. @@ -1495,12 +1503,12 @@ impl PylockTomlVcs { }; // Reconstruct the PEP 508-compatible URL from the `GitSource`. - let url = DisplaySafeUrl::from(ParsedGitUrl { + let url = DisplaySafeUrl::from(ParsedGitDirectoryUrl { url: git_url.clone(), subdirectory: subdirectory.clone(), }); - Ok(GitSourceDist { + Ok(GitDirectorySourceDist { name: name.clone(), git: Box::new(git_url), subdirectory: self.subdirectory.clone().map(Box::::from), diff --git a/crates/uv-resolver/src/lock/export/requirements_txt.rs b/crates/uv-resolver/src/lock/export/requirements_txt.rs index 9921dd6e8461b..00e93c76885b2 100644 --- a/crates/uv-resolver/src/lock/export/requirements_txt.rs +++ b/crates/uv-resolver/src/lock/export/requirements_txt.rs @@ -12,7 +12,7 @@ use uv_distribution_filename::{DistExtension, SourceDistExtension}; use uv_fs::Simplified; use uv_git_types::GitReference; use uv_normalize::PackageName; -use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl}; +use uv_pypi_types::{ParsedArchiveUrl, ParsedGitDirectoryUrl, ParsedGitPathUrl}; use uv_redacted::DisplaySafeUrl; use crate::lock::export::{ExportableRequirement, ExportableRequirements}; @@ -96,10 +96,20 @@ impl std::fmt::Display for RequirementsTxtExport<'_> { .expect("Internal Git URLs must have supported schemes"); // Reconstruct the PEP 508-compatible URL from the `GitSource`. - let url = DisplaySafeUrl::from(ParsedGitUrl { - url: git_url.clone(), - subdirectory: git.subdirectory.clone(), - }); + let url = if let Some(install_path) = git.path.as_ref() { + let ext = + DistExtension::from_path(install_path).map_err(|_| std::fmt::Error)?; + DisplaySafeUrl::from(ParsedGitPathUrl { + url: git_url.clone(), + install_path: install_path.clone(), + ext, + }) + } else { + DisplaySafeUrl::from(ParsedGitDirectoryUrl { + url: git_url.clone(), + subdirectory: git.subdirectory.clone(), + }) + }; write!(f, "{} @ {}", package.id.name, url)?; } diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index f8202dbf6c6ec..796af43b72a38 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -29,12 +29,15 @@ use uv_distribution_filename::{ }; use uv_distribution_types::{ BuiltDist, DependencyMetadata, DirectUrlBuiltDist, DirectUrlSourceDist, DirectorySourceDist, - Dist, FileLocation, GitSourceDist, Identifier, IndexLocations, IndexMetadata, IndexUrl, Name, - PYPI_URL, PathBuiltDist, PathSourceDist, RegistryBuiltDist, RegistryBuiltWheel, - RegistrySourceDist, RemoteSource, Requirement, RequirementSource, RequiresPython, ResolvedDist, - SimplifiedMarkerTree, StaticMetadata, ToUrlError, UrlString, + Dist, FileLocation, GitDirectorySourceDist, GitPathBuiltDist, GitPathSourceDist, Identifier, + IndexLocations, IndexMetadata, IndexUrl, Name, PYPI_URL, PathBuiltDist, PathSourceDist, + RegistryBuiltDist, RegistryBuiltWheel, RegistrySourceDist, RemoteSource, Requirement, + RequirementSource, RequiresPython, ResolvedDist, SimplifiedMarkerTree, StaticMetadata, + ToUrlError, UrlString, +}; +use uv_fs::{ + PortablePath, PortablePathBuf, Simplified, normalize_path, relative_to, try_relative_to_if, }; -use uv_fs::{PortablePath, PortablePathBuf, Simplified, normalize_path, try_relative_to_if}; use uv_git::{RepositoryReference, ResolvedRepositoryReference}; use uv_git_types::{GitLfs, GitOid, GitReference, GitUrl, GitUrlParseError}; use uv_normalize::{ExtraName, GroupName, PackageName}; @@ -47,7 +50,7 @@ use uv_platform_tags::{ }; use uv_pypi_types::{ ConflictKind, Conflicts, HashAlgorithm, HashDigest, HashDigests, Hashes, ParsedArchiveUrl, - ParsedGitUrl, PyProjectToml, + ParsedGitDirectoryUrl, ParsedGitPathUrl, PyProjectToml, }; use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError}; use uv_small_str::SmallString; @@ -2992,12 +2995,47 @@ impl Package { let built_dist = BuiltDist::DirectUrl(direct_dist); Dist::Built(built_dist) } - Source::Git(_, _) => { - return Err(LockErrorKind::InvalidWheelSource { - id: self.id.clone(), - source_type: "Git", - } - .into()); + Source::Git(url, git) => { + let Some(install_path) = git.path.as_ref() else { + return Err(LockErrorKind::InvalidWheelSource { + id: self.id.clone(), + source_type: "Git", + } + .into()); + }; + + // Remove the fragment and query from the URL; they're already present in the + // `GitSource`. + let mut url = url.to_url().map_err(LockErrorKind::InvalidUrl)?; + url.set_fragment(None); + url.set_query(None); + + // Reconstruct the `GitUrl` from the `GitSource`. + let git_url = GitUrl::from_commit( + url, + GitReference::from(git.kind.clone()), + git.precise, + git.lfs, + )?; + + // Reconstruct the PEP 508-compatible URL from the `GitSource`. + let url = DisplaySafeUrl::from(ParsedGitPathUrl { + url: git_url.clone(), + install_path: install_path.clone(), + ext: DistExtension::Wheel, + }); + + let filename: WheelFilename = + self.wheels[best_wheel_index].filename.clone(); + + let git_dist = GitPathBuiltDist { + filename, + git: Box::new(git_url), + install_path: install_path.clone(), + url: VerbatimUrl::from_url(url), + }; + let built_dist = BuiltDist::GitPath(git_dist); + Dist::Built(built_dist) } Source::Directory(_) => { return Err(LockErrorKind::InvalidWheelSource { @@ -3170,7 +3208,6 @@ impl Package { url.set_fragment(None); url.set_query(None); - // Reconstruct the `GitUrl` from the `GitSource`. let git_url = GitUrl::from_commit( url, GitReference::from(git.kind.clone()), @@ -3178,19 +3215,47 @@ impl Package { git.lfs, )?; - // Reconstruct the PEP 508-compatible URL from the `GitSource`. - let url = DisplaySafeUrl::from(ParsedGitUrl { - url: git_url.clone(), - subdirectory: git.subdirectory.clone(), - }); + if let Some(install_path) = git.path.as_ref() { + // A direct path source can also be a wheel, so validate the extension. + let DistExtension::Source(ext) = DistExtension::from_path(install_path) + .map_err(|err| LockErrorKind::MissingExtension { + id: self.id.clone(), + err, + })? + else { + return Ok(None); + }; - let git_dist = GitSourceDist { - name: self.id.name.clone(), - url: VerbatimUrl::from_url(url), - git: Box::new(git_url), - subdirectory: git.subdirectory.clone(), - }; - uv_distribution_types::SourceDist::Git(git_dist) + // Reconstruct the PEP 508-compatible URL from the `GitSource`. + let url = DisplaySafeUrl::from(ParsedGitPathUrl { + url: git_url.clone(), + install_path: install_path.clone(), + ext: DistExtension::Source(ext), + }); + + let git_dist = GitPathSourceDist { + name: self.id.name.clone(), + url: VerbatimUrl::from_url(url), + git: Box::new(git_url), + install_path: install_path.clone(), + ext, + }; + uv_distribution_types::SourceDist::GitPath(git_dist) + } else { + // Reconstruct the PEP 508-compatible URL from the `GitSource`. + let url = DisplaySafeUrl::from(ParsedGitDirectoryUrl { + url: git_url.clone(), + subdirectory: git.subdirectory.clone(), + }); + + let git_dist = GitDirectorySourceDist { + name: self.id.name.clone(), + url: VerbatimUrl::from_url(url), + git: Box::new(git_url), + subdirectory: git.subdirectory.clone(), + }; + uv_distribution_types::SourceDist::GitDirectory(git_dist) + } } Source::Direct(url, direct) => { // A direct URL source can also be a wheel, so validate the extension. @@ -3922,6 +3987,7 @@ impl Source { BuiltDist::Registry(ref reg_dist) => Self::from_registry_built_dist(reg_dist, root), BuiltDist::DirectUrl(ref direct_dist) => Ok(Self::from_direct_built_dist(direct_dist)), BuiltDist::Path(ref path_dist) => Self::from_path_built_dist(path_dist, root), + BuiltDist::GitPath(ref git_dist) => Self::from_git_path_built_dist(git_dist, root), } } @@ -3936,8 +4002,11 @@ impl Source { uv_distribution_types::SourceDist::DirectUrl(ref direct_dist) => { Ok(Self::from_direct_source_dist(direct_dist)) } - uv_distribution_types::SourceDist::Git(ref git_dist) => { - Ok(Self::from_git_dist(git_dist)) + uv_distribution_types::SourceDist::GitDirectory(ref git_dist) => { + Ok(Self::from_git_directory_source_dist(git_dist)) + } + uv_distribution_types::SourceDist::GitPath(ref git_dist) => { + Self::from_git_path_source_dist(git_dist, root) } uv_distribution_types::SourceDist::Path(ref path_dist) => { Self::from_path_source_dist(path_dist, root) @@ -4037,15 +4106,70 @@ impl Source { } } - fn from_git_dist(git_dist: &GitSourceDist) -> Self { + fn from_git_path_built_dist( + git_dist: &GitPathBuiltDist, + root: &Path, + ) -> Result { + let path = relative_to(&git_dist.install_path, root) + .or_else(|_| std::path::absolute(&git_dist.install_path)) + .map_err(LockErrorKind::DistributionRelativePath)?; + Ok(Self::Git( + UrlString::from(locked_git_url( + &git_dist.git, + None, + Some(git_dist.install_path.as_path()), + )), + GitSource { + kind: GitSourceKind::from(git_dist.git.reference().clone()), + precise: git_dist.git.precise().unwrap_or_else(|| { + panic!("Git distribution is missing a precise hash: {git_dist}") + }), + subdirectory: None, + path: Some(path), + lfs: git_dist.git.lfs(), + }, + )) + } + + fn from_git_path_source_dist( + git_dist: &GitPathSourceDist, + root: &Path, + ) -> Result { + let path = relative_to(&git_dist.install_path, root) + .or_else(|_| std::path::absolute(&git_dist.install_path)) + .map_err(LockErrorKind::DistributionRelativePath)?; + Ok(Self::Git( + UrlString::from(locked_git_url( + &git_dist.git, + None, + Some(git_dist.install_path.as_path()), + )), + GitSource { + kind: GitSourceKind::from(git_dist.git.reference().clone()), + precise: git_dist.git.precise().unwrap_or_else(|| { + panic!("Git distribution is missing a precise hash: {git_dist}") + }), + subdirectory: None, + path: Some(path), + lfs: git_dist.git.lfs(), + }, + )) + } + + fn from_git_directory_source_dist(git_dist: &GitDirectorySourceDist) -> Self { Self::Git( - UrlString::from(locked_git_url(git_dist)), + UrlString::from(locked_git_url( + &git_dist.git, + git_dist.subdirectory.as_deref(), + None, + )), GitSource { kind: GitSourceKind::from(git_dist.git.reference().clone()), precise: git_dist.git.precise().unwrap_or_else(|| { panic!("Git distribution is missing a precise hash: {git_dist}") }), subdirectory: git_dist.subdirectory.clone(), + path: None, lfs: git_dist.git.lfs(), }, ) @@ -4206,9 +4330,8 @@ impl Source { match self { Self::Registry(..) => None, Self::Direct(..) | Self::Path(..) => Some(true), - Self::Git(..) | Self::Directory(..) | Self::Editable(..) | Self::Virtual(..) => { - Some(false) - } + Self::Git(.., GitSource { path, .. }) => Some(path.is_some()), + Self::Directory(..) | Self::Editable(..) | Self::Virtual(..) => Some(false), } } } @@ -4368,6 +4491,7 @@ struct DirectSource { struct GitSource { precise: GitOid, subdirectory: Option>, + path: Option, kind: GitSourceKind, lfs: GitLfs, } @@ -4386,6 +4510,7 @@ impl GitSource { let mut kind = GitSourceKind::DefaultBranch; let mut subdirectory = None; let mut lfs = GitLfs::Disabled; + let mut path = None; for (key, val) in url.query_pairs() { match &*key { "tag" => kind = GitSourceKind::Tag(val.into_owned()), @@ -4393,6 +4518,11 @@ impl GitSource { "rev" => kind = GitSourceKind::Rev(val.into_owned()), "subdirectory" => subdirectory = Some(PortablePathBuf::from(val.as_ref()).into()), "lfs" => lfs = GitLfs::from(val.eq_ignore_ascii_case("true")), + "path" => { + path = Some(PathBuf::from(Box::::from(PortablePathBuf::from( + val.as_ref(), + )))); + } _ => {} } } @@ -4403,6 +4533,7 @@ impl GitSource { Ok(Self { precise, subdirectory, + path, kind, lfs, }) @@ -4549,10 +4680,10 @@ impl SourceDist { uv_distribution_types::SourceDist::Path(_) => { Self::from_path_dist(id, hashes).map(Some) } - // An actual sdist entry in the lockfile is only required when - // it's from a registry or a direct URL. Otherwise, it's strictly - // redundant with the information in all other kinds of `source`. - uv_distribution_types::SourceDist::Git(_) + uv_distribution_types::SourceDist::GitPath(_) => { + Self::from_git_path_dist(id, hashes).map(Some) + } + uv_distribution_types::SourceDist::GitDirectory(_) | uv_distribution_types::SourceDist::Directory(_) => Ok(None), } } @@ -4683,6 +4814,24 @@ impl SourceDist { }, }) } + + fn from_git_path_dist(id: &PackageId, hashes: &[HashDigest]) -> Result { + let Some(hash) = hashes.iter().max().cloned().map(Hash::from) else { + let kind = LockErrorKind::Hash { + id: id.clone(), + artifact_type: "Git archive source distribution", + expected: true, + }; + return Err(kind.into()); + }; + Ok(Self::Metadata { + metadata: SourceDistMetadata { + hash: Some(hash), + size: None, + upload_time: None, + }, + }) + } } #[derive(Clone, Debug, serde::Deserialize)] @@ -4770,9 +4919,13 @@ impl From for GitReference { } } -/// Construct the lockfile-compatible [`DisplaySafeUrl`] for a [`GitSourceDist`]. -fn locked_git_url(git_dist: &GitSourceDist) -> DisplaySafeUrl { - let mut url = git_dist.git.url().clone(); +/// Construct the lockfile-compatible [`DisplaySafeUrl`] for a [`GitUrl`]. +fn locked_git_url( + git: &GitUrl, + subdirectory: Option<&Path>, + path: Option<&Path>, +) -> DisplaySafeUrl { + let mut url = git.url().clone(); // Remove the credentials. url.remove_credentials(); @@ -4782,9 +4935,7 @@ fn locked_git_url(git_dist: &GitSourceDist) -> DisplaySafeUrl { url.set_query(None); // Put the subdirectory in the query. - if let Some(subdirectory) = git_dist - .subdirectory - .as_deref() + if let Some(subdirectory) = subdirectory .map(PortablePath::from) .as_ref() .map(PortablePath::to_string) @@ -4793,13 +4944,22 @@ fn locked_git_url(git_dist: &GitSourceDist) -> DisplaySafeUrl { .append_pair("subdirectory", &subdirectory); } + // Put the path in the query. + if let Some(path) = path + .map(PortablePath::from) + .as_ref() + .map(PortablePath::to_string) + { + url.query_pairs_mut().append_pair("path", &path); + } + // Put lfs=true in the package source git url only when explicitly enabled. - if git_dist.git.lfs().enabled() { + if git.lfs().enabled() { url.query_pairs_mut().append_pair("lfs", "true"); } // Put the requested reference in the query. - match git_dist.git.reference() { + match git.reference() { GitReference::Branch(branch) => { url.query_pairs_mut().append_pair("branch", branch.as_str()); } @@ -4815,14 +4975,7 @@ fn locked_git_url(git_dist: &GitSourceDist) -> DisplaySafeUrl { } // Put the precise commit in the fragment. - url.set_fragment( - git_dist - .git - .precise() - .as_ref() - .map(GitOid::to_string) - .as_deref(), - ); + url.set_fragment(git.precise().as_ref().map(GitOid::to_string).as_deref()); url } @@ -4914,6 +5067,9 @@ impl Wheel { Ok(vec![Self::from_direct_dist(direct_dist, hashes)]) } BuiltDist::Path(ref path_dist) => Ok(vec![Self::from_path_dist(path_dist, hashes)]), + BuiltDist::GitPath(ref git_dist) => { + Ok(vec![Self::from_git_path_dist(git_dist, hashes)]) + } } } @@ -5013,6 +5169,19 @@ impl Wheel { } } + fn from_git_path_dist(path_dist: &GitPathBuiltDist, hashes: &[HashDigest]) -> Self { + Self { + url: WheelWireSource::Filename { + filename: path_dist.filename.clone(), + }, + hash: hashes.iter().max().cloned().map(Hash::from), + size: None, + upload_time: None, + filename: path_dist.filename.clone(), + zstd: None, + } + } + pub(crate) fn to_registry_wheel( &self, source: &RegistrySource, @@ -5532,7 +5701,7 @@ fn normalize_requirement( // Normalize the requirement source. match requirement.source { - RequirementSource::Git { + RequirementSource::GitDirectory { git, subdirectory, url: _, @@ -5557,7 +5726,7 @@ fn normalize_requirement( }; // Reconstruct the PEP 508 URL from the underlying data. - let url = DisplaySafeUrl::from(ParsedGitUrl { + let url = DisplaySafeUrl::from(ParsedGitDirectoryUrl { url: git.clone(), subdirectory: subdirectory.clone(), }); @@ -5567,7 +5736,7 @@ fn normalize_requirement( extras: requirement.extras, groups: requirement.groups, marker: requires_python.simplify_markers(requirement.marker), - source: RequirementSource::Git { + source: RequirementSource::GitDirectory { git, subdirectory, url: VerbatimUrl::from_url(url), @@ -5575,6 +5744,52 @@ fn normalize_requirement( origin: None, }) } + RequirementSource::GitPath { + git, + install_path, + ext, + url: _, + } => { + // Reconstruct the Git URL. + let git = { + let mut repository = git.url().clone(); + + // Remove the credentials. + repository.remove_credentials(); + + // Remove the fragment and query from the URL; they're already present in the source. + repository.set_fragment(None); + repository.set_query(None); + + GitUrl::from_fields( + repository, + git.reference().clone(), + git.precise(), + git.lfs(), + )? + }; + + // Reconstruct the PEP 508 URL from the underlying data. + let url = DisplaySafeUrl::from(ParsedGitPathUrl { + url: git.clone(), + install_path: install_path.clone(), + ext, + }); + + Ok(Requirement { + name: requirement.name, + extras: requirement.extras, + groups: requirement.groups, + marker: requires_python.simplify_markers(requirement.marker), + source: RequirementSource::GitPath { + git, + install_path, + ext, + url: VerbatimUrl::from_url(url), + }, + origin: None, + }) + } RequirementSource::Path { install_path, ext, @@ -6179,7 +6394,7 @@ enum LockErrorKind { /// The specific type of artifact, e.g., "source package" /// or "wheel". artifact_type: &'static str, - /// When true, a hash is expected to be present. + /// Whether a hash was expected. expected: bool, }, /// An error that occurs when a package is included with an extra name, diff --git a/crates/uv-resolver/src/pubgrub/dependencies.rs b/crates/uv-resolver/src/pubgrub/dependencies.rs index 82dc238edc1e1..b3416411e4c37 100644 --- a/crates/uv-resolver/src/pubgrub/dependencies.rs +++ b/crates/uv-resolver/src/pubgrub/dependencies.rs @@ -9,8 +9,8 @@ use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::RequirementOrigin; use uv_pypi_types::{ - ConflictItemRef, Conflicts, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, - ParsedUrl, VerbatimParsedUrl, + ConflictItemRef, Conflicts, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitDirectoryUrl, + ParsedGitPathUrl, ParsedPathUrl, ParsedUrl, VerbatimParsedUrl, }; use crate::pubgrub::{PubGrubPackage, PubGrubPackageInner}; @@ -51,7 +51,8 @@ impl DependencySource { } RequirementSource::Registry { .. } => Self::Unspecified, RequirementSource::Url { .. } - | RequirementSource::Git { .. } + | RequirementSource::GitDirectory { .. } + | RequirementSource::GitPath { .. } | RequirementSource::Path { .. } | RequirementSource::Directory { .. } => requirement .source @@ -281,13 +282,28 @@ impl PubGrubRequirement { )); (url, parsed_url) } - RequirementSource::Git { + RequirementSource::GitDirectory { git, url, subdirectory, } => { - let parsed_url = - ParsedUrl::Git(ParsedGitUrl::from_source(git.clone(), subdirectory.clone())); + let parsed_url = ParsedUrl::GitDirectory(ParsedGitDirectoryUrl::from_source( + git.clone(), + subdirectory.clone(), + )); + (url, parsed_url) + } + RequirementSource::GitPath { + git, + install_path, + ext, + url, + } => { + let parsed_url = ParsedUrl::GitPath(ParsedGitPathUrl::from_source( + git.clone(), + install_path.clone(), + *ext, + )); (url, parsed_url) } RequirementSource::Path { diff --git a/crates/uv-resolver/src/redirect.rs b/crates/uv-resolver/src/redirect.rs index 6d0696cd65824..ee2b8d8a1d0f6 100644 --- a/crates/uv-resolver/src/redirect.rs +++ b/crates/uv-resolver/src/redirect.rs @@ -1,35 +1,60 @@ use uv_git::GitResolver; use uv_pep508::VerbatimUrl; -use uv_pypi_types::{ParsedGitUrl, ParsedUrl, VerbatimParsedUrl}; +use uv_pypi_types::{ParsedGitDirectoryUrl, ParsedGitPathUrl, ParsedUrl, VerbatimParsedUrl}; use uv_redacted::DisplaySafeUrl; /// Map a URL to a precise URL, if possible. pub(crate) fn url_to_precise(url: VerbatimParsedUrl, git: &GitResolver) -> VerbatimParsedUrl { - let ParsedUrl::Git(ParsedGitUrl { - url: git_url, - subdirectory, - }) = &url.parsed_url - else { - return url; - }; - - let Some(new_git_url) = git.precise(git_url.clone()) else { - if cfg!(debug_assertions) { - panic!("Unresolved Git URL: {}, {git_url:?}", url.verbatim); - } else { - return url; + match &url.parsed_url { + ParsedUrl::GitDirectory(ParsedGitDirectoryUrl { + url: git_url, + subdirectory, + }) => { + let Some(new_git_url) = git.precise(git_url.clone()) else { + if cfg!(debug_assertions) { + panic!("Unresolved Git URL: {}, {git_url:?}", url.verbatim); + } else { + return url; + } + }; + + let new_parsed_url = ParsedGitDirectoryUrl { + url: new_git_url, + subdirectory: subdirectory.clone(), + }; + let new_url = DisplaySafeUrl::from(new_parsed_url.clone()); + let new_verbatim_url = apply_redirect(&url.verbatim, new_url); + VerbatimParsedUrl { + parsed_url: ParsedUrl::GitDirectory(new_parsed_url), + verbatim: new_verbatim_url, + } + } + ParsedUrl::GitPath(ParsedGitPathUrl { + url: git_url, + install_path, + ext, + }) => { + let Some(new_git_url) = git.precise(git_url.clone()) else { + if cfg!(debug_assertions) { + panic!("Unresolved Git URL: {}, {git_url:?}", url.verbatim); + } else { + return url; + } + }; + + let new_parsed_url = ParsedGitPathUrl { + url: new_git_url, + install_path: install_path.clone(), + ext: *ext, + }; + let new_url = DisplaySafeUrl::from(new_parsed_url.clone()); + let new_verbatim_url = apply_redirect(&url.verbatim, new_url); + VerbatimParsedUrl { + parsed_url: ParsedUrl::GitPath(new_parsed_url), + verbatim: new_verbatim_url, + } } - }; - - let new_parsed_url = ParsedGitUrl { - url: new_git_url, - subdirectory: subdirectory.clone(), - }; - let new_url = DisplaySafeUrl::from(new_parsed_url.clone()); - let new_verbatim_url = apply_redirect(&url.verbatim, new_url); - VerbatimParsedUrl { - parsed_url: ParsedUrl::Git(new_parsed_url), - verbatim: new_verbatim_url, + _ => url, } } diff --git a/crates/uv-resolver/src/resolution/mod.rs b/crates/uv-resolver/src/resolution/mod.rs index edc69e10d8327..25704ab2369fb 100644 --- a/crates/uv-resolver/src/resolution/mod.rs +++ b/crates/uv-resolver/src/resolution/mod.rs @@ -55,13 +55,15 @@ impl AnnotatedDist { BuiltDist::Registry(dist) => Some(&dist.best_wheel().index), BuiltDist::DirectUrl(_) => None, BuiltDist::Path(_) => None, + BuiltDist::GitPath(_) => None, }, Dist::Source(dist) => match dist { SourceDist::Registry(dist) => Some(&dist.index), SourceDist::DirectUrl(_) => None, - SourceDist::Git(_) => None, SourceDist::Path(_) => None, SourceDist::Directory(_) => None, + SourceDist::GitPath(_) => None, + SourceDist::GitDirectory(_) => None, }, }, } diff --git a/crates/uv-resolver/src/resolution/output.rs b/crates/uv-resolver/src/resolution/output.rs index fe514855d4e58..b68a9899b3799 100644 --- a/crates/uv-resolver/src/resolution/output.rs +++ b/crates/uv-resolver/src/resolution/output.rs @@ -417,7 +417,6 @@ impl ResolverOutput { // Create the locked distribution and recover the metadata using the original URL that // was requested during resolution. let dist = Dist::from_url(name.clone(), url_to_precise(url.clone(), git))?; - let hashes_id = dist.distribution_id(); let metadata_id = Dist::from_url(name.clone(), url.clone())?.distribution_id(); // Extract the hashes. @@ -425,7 +424,7 @@ impl ResolverOutput { name, index, Some(url), - &hashes_id, + &metadata_id, version, preferences, in_memory, diff --git a/crates/uv-resolver/src/resolver/fork_map.rs b/crates/uv-resolver/src/resolver/fork_map.rs index 9a1d7bf53ad4f..50c5ed0a86731 100644 --- a/crates/uv-resolver/src/resolver/fork_map.rs +++ b/crates/uv-resolver/src/resolver/fork_map.rs @@ -53,7 +53,8 @@ impl ForkScope { let conflict = match &requirement.source { RequirementSource::Registry { conflict, .. } => conflict.clone(), RequirementSource::Url { .. } - | RequirementSource::Git { .. } + | RequirementSource::GitDirectory { .. } + | RequirementSource::GitPath { .. } | RequirementSource::Path { .. } | RequirementSource::Directory { .. } => None, }; diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index b8aeb61ca2041..365b7ec4202d8 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -1189,6 +1189,7 @@ impl ResolverState &dist.best_wheel().filename, BuiltDist::DirectUrl(dist) => &dist.filename, + BuiltDist::GitPath(dist) => &dist.filename, BuiltDist::Path(dist) => &dist.filename, }; diff --git a/crates/uv-resolver/src/resolver/urls.rs b/crates/uv-resolver/src/resolver/urls.rs index e1f4432fa555f..c4217be53576e 100644 --- a/crates/uv-resolver/src/resolver/urls.rs +++ b/crates/uv-resolver/src/resolver/urls.rs @@ -177,11 +177,15 @@ fn same_resource(a: &ParsedUrl, b: &ParsedUrl, git: &GitResolver) -> bool { == b.subdirectory.as_deref().map(uv_fs::normalize_path) && CanonicalUrl::new(&a.url) == CanonicalUrl::new(&b.url) } - (ParsedUrl::Git(a), ParsedUrl::Git(b)) => { + (ParsedUrl::GitDirectory(a), ParsedUrl::GitDirectory(b)) => { a.subdirectory.as_deref().map(uv_fs::normalize_path) == b.subdirectory.as_deref().map(uv_fs::normalize_path) && git.same_ref(&a.url, &b.url) } + (ParsedUrl::GitPath(a), ParsedUrl::GitPath(b)) => { + uv_fs::normalize_path(&a.install_path) == uv_fs::normalize_path(&b.install_path) + && git.same_ref(&a.url, &b.url) + } (ParsedUrl::Path(a), ParsedUrl::Path(b)) => { a.install_path == b.install_path || is_same_file(&a.install_path, &b.install_path).unwrap_or(false) diff --git a/crates/uv-types/src/hash.rs b/crates/uv-types/src/hash.rs index 7b2cec1919702..91d527d094025 100644 --- a/crates/uv-types/src/hash.rs +++ b/crates/uv-types/src/hash.rs @@ -379,9 +379,12 @@ impl HashStrategy { subdirectory, .. } => Some(VersionId::from_archive(location, subdirectory.as_deref())), - RequirementSource::Git { + RequirementSource::GitDirectory { git, subdirectory, .. } => Some(VersionId::from_git(git, subdirectory.as_deref())), + RequirementSource::GitPath { + git, install_path, .. + } => Some(VersionId::from_git(git, Some(install_path))), RequirementSource::Path { install_path, .. } => { Some(VersionId::from_path(install_path)) } diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 95b1751ca48b4..11db8a05cb91e 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -1197,8 +1197,10 @@ pub enum Source { Git { /// The repository URL (without the `git+` prefix). git: DisplaySafeUrl, - /// The path to the directory with the `pyproject.toml`, if it's not in the archive root. + /// The path to the directory with the `pyproject.toml`, if it's not in the repository root. subdirectory: Option, + /// The path to the archive within the repository. + path: Option, // Only one of the three may be used; we'll validate this later and emit a custom error. rev: Option, tag: Option, @@ -1358,11 +1360,6 @@ impl<'de> Deserialize<'de> for Source { "cannot specify both `git` and `workspace`", )); } - if path.is_some() { - return Err(serde::de::Error::custom( - "cannot specify both `git` and `path`", - )); - } if url.is_some() { return Err(serde::de::Error::custom( "cannot specify both `git` and `url`", @@ -1378,6 +1375,11 @@ impl<'de> Deserialize<'de> for Source { "cannot specify both `git` and `package`", )); } + if subdirectory.is_some() && path.is_some() { + return Err(serde::de::Error::custom( + "cannot specify both `subdirectory` and `path`", + )); + } // At most one of `rev`, `tag`, or `branch` may be set. match (rev.as_ref(), tag.as_ref(), branch.as_ref()) { @@ -1402,6 +1404,7 @@ impl<'de> Deserialize<'de> for Source { return Ok(Self::Git { git, subdirectory, + path, rev, tag, branch, @@ -1709,11 +1712,13 @@ impl Source { existing_sources: Option<&BTreeMap>, ) -> Result, SourceError> { // If the user specified a Git reference for a non-Git source, try existing Git sources before erroring. - if !matches!(source, RequirementSource::Git { .. }) - && (branch.is_some() - || tag.is_some() - || rev.is_some() - || matches!(lfs, GitLfsSetting::Enabled { .. })) + if !matches!( + source, + RequirementSource::GitDirectory { .. } | RequirementSource::GitPath { .. } + ) && (branch.is_some() + || tag.is_some() + || rev.is_some() + || matches!(lfs, GitLfsSetting::Enabled { .. })) { if let Some(sources) = existing_sources { if let Some(package_sources) = sources.get(name) { @@ -1721,6 +1726,7 @@ impl Source { if let Self::Git { git, subdirectory, + path, marker, extra, group, @@ -1735,6 +1741,7 @@ impl Source { branch, lfs: lfs.into(), marker: *marker, + path: path.clone(), extra: extra.clone(), group: group.clone(), })); @@ -1780,7 +1787,10 @@ impl Source { RequirementSource::Url { .. } => { Err(SourceError::WorkspacePackageUrl(name.to_string())) } - RequirementSource::Git { .. } => { + RequirementSource::GitDirectory { .. } => { + Err(SourceError::WorkspacePackageGit(name.to_string())) + } + RequirementSource::GitPath { .. } => { Err(SourceError::WorkspacePackageGit(name.to_string())) } RequirementSource::Path { .. } => { @@ -1846,7 +1856,7 @@ impl Source { extra: None, group: None, }, - RequirementSource::Git { + RequirementSource::GitDirectory { git, subdirectory, .. } => { if rev.is_none() && tag.is_none() && branch.is_none() { @@ -1865,6 +1875,7 @@ impl Source { lfs: lfs.into(), git: git.url().clone(), subdirectory: subdirectory.map(PortablePathBuf::from), + path: None, marker: MarkerTree::TRUE, extra: None, group: None, @@ -1877,6 +1888,46 @@ impl Source { lfs: lfs.into(), git: git.url().clone(), subdirectory: subdirectory.map(PortablePathBuf::from), + path: None, + marker: MarkerTree::TRUE, + extra: None, + group: None, + } + } + } + RequirementSource::GitPath { + git, install_path, .. + } => { + if rev.is_none() && tag.is_none() && branch.is_none() { + let rev = match git.reference() { + GitReference::Branch(rev) => Some(rev), + GitReference::Tag(rev) => Some(rev), + GitReference::BranchOrTag(rev) => Some(rev), + GitReference::BranchOrTagOrCommit(rev) => Some(rev), + GitReference::NamedRef(rev) => Some(rev), + GitReference::DefaultBranch => None, + }; + Self::Git { + rev: rev.cloned(), + tag, + branch, + lfs: lfs.into(), + git: git.url().clone(), + subdirectory: None, + path: Some(PortablePathBuf::from(install_path.as_path())), + marker: MarkerTree::TRUE, + extra: None, + group: None, + } + } else { + Self::Git { + rev, + tag, + branch, + lfs: lfs.into(), + git: git.url().clone(), + subdirectory: None, + path: Some(PortablePathBuf::from(install_path.as_path())), marker: MarkerTree::TRUE, extra: None, group: None, diff --git a/crates/uv/src/commands/pip/operations.rs b/crates/uv/src/commands/pip/operations.rs index dc0efa2db1399..0f76b55e5f716 100644 --- a/crates/uv/src/commands/pip/operations.rs +++ b/crates/uv/src/commands/pip/operations.rs @@ -405,7 +405,6 @@ pub(crate) enum Modifications { /// A distribution which was or would be modified #[derive(Debug, Clone, PartialEq, Eq, Hash)] -#[expect(clippy::large_enum_variant)] pub(crate) enum ChangedDist { Local(LocalDist), Remote(Arc), diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 46871efb06abf..8eb5b4f907495 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -865,6 +865,7 @@ fn edits( Some(Source::Git { mut git, subdirectory, + path, rev, tag, branch, @@ -884,6 +885,7 @@ fn edits( Some(Source::Git { git, subdirectory, + path, rev, tag, branch, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 61bc181fccec6..dce812ae0172c 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -26,7 +26,7 @@ use uv_installer::{InstallationStrategy, SitePackages}; use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; use uv_pep508::{MarkerTree, VersionOrUrl}; use uv_preview::{Preview, PreviewFeature}; -use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl, ParsedUrl}; +use uv_pypi_types::{ParsedArchiveUrl, ParsedGitDirectoryUrl, ParsedGitPathUrl, ParsedUrl}; use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; use uv_redacted::DisplaySafeUrl; use uv_resolver::{FlatIndex, ForkStrategy, Installable, Lock, PrereleaseMode, ResolutionMode}; @@ -1052,7 +1052,8 @@ fn store_credentials_from_target(target: InstallTarget<'_>, client_builder: &Bas continue; }; match &url.parsed_url { - ParsedUrl::Git(ParsedGitUrl { url, .. }) => { + ParsedUrl::GitDirectory(ParsedGitDirectoryUrl { url, .. }) + | ParsedUrl::GitPath(ParsedGitPathUrl { url, .. }) => { uv_git::store_credentials_from_url(url.url()); } ParsedUrl::Archive(ParsedArchiveUrl { url, .. }) => { diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 9122959eefb0a..235f423b9cae3 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -3967,7 +3967,7 @@ fn add_update_git_reference_project() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) + - uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@0dacfd662c64cb4ceb16e6cf65a157a8b715b979) "); @@ -3980,7 +3980,7 @@ fn add_update_git_reference_project() -> Result<()> { Resolved 2 packages in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@0dacfd662c64cb4ceb16e6cf65a157a8b715b979) + - uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage@0dacfd662c64cb4ceb16e6cf65a157a8b715b979) + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) "); @@ -3994,7 +3994,7 @@ fn add_update_git_reference_project() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) + - uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@2005223fcad0e2c06daf2e14b93b790604868e1e) "); diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 2ffcf88657cff..328d1518626a8 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -945,6 +945,246 @@ fn lock_sdist_git_short_rev() -> Result<()> { Ok(()) } +/// Lock a Git requirement that points to a pre-built source archive within a repository. +#[test] +#[cfg(feature = "test-git")] +fn lock_sdist_git_archive() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.uv.sources] + iniconfig = { git = "https://github.com/astral-sh/archive-in-git-test", path = "archives/iniconfig-2.0.0.tar.gz" } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { git = "https://github.com/astral-sh/archive-in-git-test?path=archives%2Finiconfig-2.0.0.tar.gz#bb7ce6abf9f90544767701de5b7b0c7802dc642b" } + sdist = { hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3" } + + [[package]] + name = "project" + version = "0.1.0" + source = { editable = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig", git = "https://github.com/astral-sh/archive-in-git-test?path=archives%2Finiconfig-2.0.0.tar.gz" }] + "### + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + iniconfig==2.0.0 (from git+https://github.com/astral-sh/archive-in-git-test@bb7ce6abf9f90544767701de5b7b0c7802dc642b#path=archives/iniconfig-2.0.0.tar.gz) + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + + // Re-install from the lockfile. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Checked 2 packages in [TIME] + "###); + + // Clear the environment, and re-install. + fs_err::remove_dir_all(&context.venv)?; + + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Installed 2 packages in [TIME] + + iniconfig==2.0.0 (from git+https://github.com/astral-sh/archive-in-git-test@bb7ce6abf9f90544767701de5b7b0c7802dc642b#path=archives/iniconfig-2.0.0.tar.gz) + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + + Ok(()) +} + +/// Lock a Git requirement that points to a pre-built wheel within a repository. +#[test] +#[cfg(feature = "test-git")] +fn lock_wheel_git_archive() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.uv.sources] + iniconfig = { git = "https://github.com/astral-sh/archive-in-git-test", path = "archives/iniconfig-2.0.0-py3-none-any.whl" } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { git = "https://github.com/astral-sh/archive-in-git-test?path=archives%2Finiconfig-2.0.0-py3-none-any.whl#bb7ce6abf9f90544767701de5b7b0c7802dc642b" } + wheels = [ + { filename = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { editable = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig", git = "https://github.com/astral-sh/archive-in-git-test?path=archives%2Finiconfig-2.0.0-py3-none-any.whl" }] + "### + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + iniconfig==2.0.0 (from git+https://github.com/astral-sh/archive-in-git-test@bb7ce6abf9f90544767701de5b7b0c7802dc642b#path=archives/iniconfig-2.0.0-py3-none-any.whl) + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + + // Re-install from the lockfile. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Checked 2 packages in [TIME] + "###); + + // Clear the environment, and re-install. + fs_err::remove_dir_all(&context.venv)?; + + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Installed 2 packages in [TIME] + + iniconfig==2.0.0 (from git+https://github.com/astral-sh/archive-in-git-test@bb7ce6abf9f90544767701de5b7b0c7802dc642b#path=archives/iniconfig-2.0.0-py3-none-any.whl) + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + + Ok(()) +} + /// Lock a requirement from a direct URL to a wheel. #[test] fn lock_wheel_url() -> Result<()> { diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index e7f6fda1a989e..5addc44a9704e 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -10933,6 +10933,98 @@ fn sync_git_repeated_member_backwards_path() -> Result<()> { Ok(()) } +/// A Git repository that points to a pre-built archive within the repository. +#[test] +#[cfg(feature = "test-git")] +fn sync_git_path_archive() -> Result<()> { + let context = uv_test::test_context!("3.13"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "foo" + version = "0.1.0" + requires-python = ">=3.13" + dependencies = ["archive-in-git-test"] + + [tool.uv.sources] + archive-in-git-test = { git = "https://github.com/astral-sh/archive-in-git-test.git" } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + + insta::with_settings!( + { + filters => context.filters(), + }, + { + assert_snapshot!( + lock, @r###" + version = 1 + revision = 3 + requires-python = ">=3.13" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "archive-in-git-test" + version = "0.1.0" + source = { git = "https://github.com/astral-sh/archive-in-git-test.git#bb7ce6abf9f90544767701de5b7b0c7802dc642b" } + dependencies = [ + { name = "iniconfig" }, + ] + + [[package]] + name = "foo" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "archive-in-git-test" }, + ] + + [package.metadata] + requires-dist = [{ name = "archive-in-git-test", git = "https://github.com/astral-sh/archive-in-git-test.git" }] + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { git = "https://github.com/astral-sh/archive-in-git-test.git?path=archives%2Finiconfig-2.0.0-py3-none-any.whl#bb7ce6abf9f90544767701de5b7b0c7802dc642b" } + wheels = [ + { filename = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" }, + ] + "### + ); + } + ); + + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + archive-in-git-test==0.1.0 (from git+https://github.com/astral-sh/archive-in-git-test.git@bb7ce6abf9f90544767701de5b7b0c7802dc642b) + + iniconfig==2.0.0 (from git+https://github.com/astral-sh/archive-in-git-test.git@bb7ce6abf9f90544767701de5b7b0c7802dc642b#path=archives/iniconfig-2.0.0-py3-none-any.whl) + "###); + + Ok(()) +} + /// The project itself is marked as an editable dependency, but under the wrong name. The project /// is a package. #[test] @@ -15075,7 +15167,7 @@ fn sync_git_lfs() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) + - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) + test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb) "); @@ -15184,7 +15276,7 @@ fn sync_git_lfs() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb) + - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@261c828b8e05251f3a3e4f6b47b149d691c7efbb) + test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) "); @@ -15211,7 +15303,7 @@ fn sync_git_lfs() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) + - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) + test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb) "); @@ -15243,7 +15335,7 @@ fn sync_git_lfs() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb) + - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@261c828b8e05251f3a3e4f6b47b149d691c7efbb) + test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) "); @@ -15269,7 +15361,7 @@ fn sync_git_lfs() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) + - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) + test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb) "); @@ -15333,7 +15425,7 @@ fn sync_git_lfs() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb) + - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@261c828b8e05251f3a3e4f6b47b149d691c7efbb) + test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) "); diff --git a/uv.schema.json b/uv.schema.json index acd6713e6aadb..c9529a3330b39 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -1801,11 +1801,22 @@ "marker": { "$ref": "#/definitions/MarkerTree" }, + "path": { + "description": "The path to the archive within the repository.", + "anyOf": [ + { + "$ref": "#/definitions/PortablePathBuf" + }, + { + "type": "null" + } + ] + }, "rev": { "type": ["string", "null"] }, "subdirectory": { - "description": "The path to the directory with the `pyproject.toml`, if it's not in the archive root.", + "description": "The path to the directory with the `pyproject.toml`, if it's not in the repository root.", "anyOf": [ { "$ref": "#/definitions/PortablePathBuf"