From 853c4fd6f16c4aa3c3e1ba4caa4871e2dc75f2e3 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 18 Dec 2024 21:32:58 -0500 Subject: [PATCH] Add support for path deps in git --- Cargo.lock | 2 + crates/uv-client/Cargo.toml | 1 + crates/uv-client/src/error.rs | 3 + crates/uv-client/src/registry_client.rs | 34 ++ crates/uv-client/tests/it/remote_metadata.rs | 7 +- crates/uv-dev/Cargo.toml | 1 + crates/uv-dev/src/wheel_metadata.rs | 4 + crates/uv-distribution-types/src/buildable.rs | 48 +- crates/uv-distribution-types/src/cached.rs | 20 +- crates/uv-distribution-types/src/error.rs | 4 + crates/uv-distribution-types/src/hash.rs | 12 + crates/uv-distribution-types/src/lib.rs | 264 +++++++++-- .../uv-distribution-types/src/resolution.rs | 18 +- crates/uv-distribution-types/src/traits.rs | 24 +- .../src/distribution_database.rs | 58 ++- .../src/index/built_wheel_index.rs | 41 +- .../uv-distribution/src/index/cached_wheel.rs | 4 +- crates/uv-distribution/src/lib.rs | 2 +- .../uv-distribution/src/metadata/lowering.rs | 74 ++- crates/uv-distribution/src/metadata/mod.rs | 4 +- crates/uv-distribution/src/source/mod.rs | 425 +++++++++++++++++- crates/uv-git/src/resolver.rs | 46 +- crates/uv-git/src/source.rs | 12 +- crates/uv-installer/src/plan.rs | 72 ++- crates/uv-installer/src/satisfies.rs | 60 ++- crates/uv-pypi-types/src/direct_url.rs | 6 + crates/uv-pypi-types/src/parsed_url.rs | 145 +++++- crates/uv-pypi-types/src/requirement.rs | 240 ++++++++-- crates/uv-requirements-txt/src/requirement.rs | 10 +- ...xt__test__line-endings-whitespace.txt.snap | 4 +- ...ments_txt__test__parse-whitespace.txt.snap | 4 +- crates/uv-requirements/src/lib.rs | 37 +- crates/uv-requirements/src/unnamed.rs | 28 +- crates/uv-resolver/src/lock/mod.rs | 333 +++++++++++--- .../uv-resolver/src/lock/requirements_txt.rs | 4 +- .../uv-resolver/src/pubgrub/dependencies.rs | 25 +- crates/uv-resolver/src/redirect.rs | 80 ++-- crates/uv-resolver/src/resolution/mod.rs | 4 +- crates/uv-resolver/src/resolver/urls.rs | 6 +- crates/uv-types/src/hash.rs | 3 +- crates/uv-workspace/src/pyproject.rs | 68 ++- crates/uv/src/commands/project/add.rs | 10 +- crates/uv/src/commands/project/sync.rs | 4 +- crates/uv/tests/it/lock.rs | 238 ++++++++++ crates/uv/tests/it/sync.rs | 90 ++++ uv.schema.json | 13 +- 46 files changed, 2234 insertions(+), 358 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5761153f03fc..bd7c4c1ce700 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4726,6 +4726,7 @@ dependencies = [ "uv-distribution-filename", "uv-distribution-types", "uv-fs", + "uv-git", "uv-metadata", "uv-normalize", "uv-pep440", @@ -4803,6 +4804,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 7bea5ab97c82..a571486f6773 100644 --- a/crates/uv-client/Cargo.toml +++ b/crates/uv-client/Cargo.toml @@ -17,6 +17,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 30df2fe0d769..0bff15e9b4d1 100644 --- a/crates/uv-client/src/error.rs +++ b/crates/uv-client/src/error.rs @@ -144,6 +144,9 @@ pub enum ErrorKind { #[error(transparent)] JoinRelativeUrl(#[from] uv_pypi_types::JoinRelativeError), + #[error(transparent)] + Git(#[from] uv_git::GitResolverError), + #[error("Expected a file URL, but received: {0}")] NonFileUrl(Url), diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs index 6cbb0d8c1b6a..6308f68c773a 100644 --- a/crates/uv-client/src/registry_client.rs +++ b/crates/uv-client/src/registry_client.rs @@ -8,6 +8,7 @@ use std::collections::BTreeMap; use std::fmt::Debug; use std::path::PathBuf; use std::str::FromStr; +use std::sync::Arc; use std::time::Duration; use tracing::{info_span, instrument, trace, warn, Instrument}; use url::Url; @@ -19,6 +20,7 @@ use uv_distribution_filename::{DistFilename, SourceDistFilename, WheelFilename}; use uv_distribution_types::{ BuiltDist, File, FileLocation, Index, IndexCapabilities, 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; @@ -446,7 +448,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) => { @@ -539,6 +543,36 @@ impl RegistryClient { ) })? } + BuiltDist::GitPath(wheel) => { + // Fetch the Git repository. + let fetch = git + .fetch( + &wheel.git, + self.uncached_client(&wheel.url).clone(), + 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 0570bba4d720..e329eaf4d280 100644 --- a/crates/uv-client/tests/it/remote_metadata.rs +++ b/crates/uv-client/tests/it/remote_metadata.rs @@ -7,6 +7,7 @@ use uv_cache::Cache; use uv_client::RegistryClientBuilder; use uv_distribution_filename::WheelFilename; use uv_distribution_types::{BuiltDist, DirectUrlBuiltDist, IndexCapabilities}; +use uv_git::GitResolver; use uv_pep508::VerbatimUrl; #[tokio::test] @@ -24,8 +25,12 @@ async fn remote_metadata_with_and_without_cache() -> Result<()> { location: Url::parse(url).unwrap(), url: VerbatimUrl::from_str(url).unwrap(), }); + let resolver = GitResolver::default(); let capabilities = IndexCapabilities::default(); - let metadata = client.wheel_metadata(&dist, &capabilities).await.unwrap(); + let metadata = client + .wheel_metadata(&dist, &resolver, &capabilities, None) + .await + .unwrap(); assert_eq!(metadata.version.to_string(), "4.66.1"); } diff --git a/crates/uv-dev/Cargo.toml b/crates/uv-dev/Cargo.toml index aa634af23160..146223f5b31c 100644 --- a/crates/uv-dev/Cargo.toml +++ b/crates/uv-dev/Cargo.toml @@ -22,6 +22,7 @@ uv-client = { workspace = true } uv-distribution-filename = { workspace = true } uv-distribution-types = { workspace = true } uv-extract = { workspace = true, optional = 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 421709810237..ba5d2eca431f 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::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; @@ -21,6 +22,7 @@ pub(crate) struct WheelMetadataArgs { pub(crate) async fn wheel_metadata(args: WheelMetadataArgs) -> Result<()> { let cache = Cache::try_from(args.cache_args)?.init()?; let client = RegistryClientBuilder::new(cache).build(); + let resolver = GitResolver::default(); let capabilities = IndexCapabilities::default(); let filename = WheelFilename::from_str(&args.url.filename()?)?; @@ -36,7 +38,9 @@ pub(crate) async fn wheel_metadata(args: WheelMetadataArgs) -> Result<()> { location: archive.url, url: args.url, }), + &resolver, &capabilities, + None, ) .await?; println!("{metadata:?}"); diff --git a/crates/uv-distribution-types/src/buildable.rs b/crates/uv-distribution-types/src/buildable.rs index de004393b492..1668d17f1a04 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 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. /// @@ -88,7 +91,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>), } @@ -98,7 +102,8 @@ impl SourceUrl<'_> { pub fn url(&self) -> &Url { 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, } @@ -122,7 +127,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}"), } @@ -143,7 +149,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, @@ -151,14 +183,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 14cd1358b65b..de7beb489dd2 100644 --- a/crates/uv-distribution-types/src/cached.rs +++ b/crates/uv-distribution-types/src/cached.rs @@ -75,6 +75,15 @@ impl CachedDist { editable: false, r#virtual: false, }), + Dist::Built(BuiltDist::GitPath(dist)) => Self::Url(CachedDirectUrlDist { + filename, + url: dist.url, + hashes, + cache_info, + path, + editable: false, + r#virtual: false, + }), Dist::Source(SourceDist::Registry(_dist)) => Self::Registry(CachedRegistryDist { filename, path, @@ -90,7 +99,16 @@ impl CachedDist { editable: false, r#virtual: false, }), - Dist::Source(SourceDist::Git(dist)) => Self::Url(CachedDirectUrlDist { + Dist::Source(SourceDist::GitDirectory(dist)) => Self::Url(CachedDirectUrlDist { + filename, + url: dist.url, + hashes, + cache_info, + path, + editable: false, + r#virtual: false, + }), + Dist::Source(SourceDist::GitPath(dist)) => Self::Url(CachedDirectUrlDist { filename, url: dist.url, hashes, diff --git a/crates/uv-distribution-types/src/error.rs b/crates/uv-distribution-types/src/error.rs index 8c03d5b40bfa..ad39886b0006 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 url::Url; use uv_normalize::PackageName; @@ -16,6 +17,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(Url), diff --git a/crates/uv-distribution-types/src/hash.rs b/crates/uv-distribution-types/src/hash.rs index ff668b6a1e7a..547953e022ff 100644 --- a/crates/uv-distribution-types/src/hash.rs +++ b/crates/uv-distribution-types/src/hash.rs @@ -82,3 +82,15 @@ pub trait Hashed { } } } + +impl Hashed for Vec { + fn hashes(&self) -> &[HashDigest] { + self + } +} + +impl Hashed for &[HashDigest] { + fn hashes(&self) -> &[HashDigest] { + self + } +} diff --git a/crates/uv-distribution-types/src/lib.rs b/crates/uv-distribution-types/src/lib.rs index 2407a2532095..84a27ab65e4a 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,6 +33,7 @@ //! //! 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::path; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -190,6 +191,7 @@ pub enum BuiltDist { Registry(RegistryBuiltDist), DirectUrl(DirectUrlBuiltDist), Path(PathBuiltDist), + GitPath(GitPathBuiltDist), } /// A source distribution, with its possible origins (index, url, path, git) @@ -198,7 +200,8 @@ pub enum BuiltDist { pub enum SourceDist { Registry(RegistrySourceDist), DirectUrl(DirectUrlSourceDist), - Git(GitSourceDist), + GitDirectory(GitDirectorySourceDist), + GitPath(GitPathSourceDist), Path(PathSourceDist), Directory(DirectorySourceDist), } @@ -263,6 +266,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 { @@ -298,9 +313,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, @@ -310,6 +325,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 { @@ -399,7 +429,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, @@ -463,19 +497,64 @@ impl Dist { }))) } - /// A remote source distribution from a `git+https://` or `git+ssh://` url. - pub 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, ) -> Result { - Ok(Self::Source(SourceDist::Git(GitSourceDist { - name, - git: Box::new(git), - subdirectory, - url, - }))) + 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. @@ -498,8 +577,11 @@ impl Dist { directory.editable, directory.r#virtual, ), - ParsedUrl::Git(git) => { - 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) } } } @@ -586,6 +668,7 @@ impl BuiltDist { Self::Registry(registry) => Some(®istry.best_wheel().index), Self::DirectUrl(_) => None, Self::Path(_) => None, + Self::GitPath(_) => None, } } @@ -593,7 +676,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, } } @@ -602,6 +685,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, } } } @@ -611,7 +695,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, } } @@ -619,14 +707,22 @@ 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, } } 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, } } @@ -684,6 +780,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 @@ -696,7 +798,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 } @@ -719,7 +827,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(), } @@ -732,6 +841,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(), } } } @@ -769,6 +879,18 @@ impl DistributionMetadata for PathBuiltDist { } } +impl DistributionMetadata for GitPathBuiltDist { + fn version_or_url(&self) -> VersionOrUrlRef { + VersionOrUrlRef::Url(&self.url) + } +} + +impl DistributionMetadata for GitDirectorySourceDist { + fn version_or_url(&self) -> VersionOrUrlRef { + VersionOrUrlRef::Url(&self.url) + } +} + impl DistributionMetadata for RegistrySourceDist { fn version_or_url(&self) -> VersionOrUrlRef { VersionOrUrlRef::Version(&self.version) @@ -781,7 +903,7 @@ impl DistributionMetadata for DirectUrlSourceDist { } } -impl DistributionMetadata for GitSourceDist { +impl DistributionMetadata for GitPathSourceDist { fn version_or_url(&self) -> VersionOrUrlRef { VersionOrUrlRef::Url(&self.url) } @@ -804,7 +926,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(), } @@ -817,6 +940,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(), } } } @@ -931,7 +1055,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()? { @@ -967,6 +1117,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() @@ -992,7 +1152,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(), } @@ -1002,7 +1163,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(), } @@ -1015,6 +1177,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(), } } @@ -1023,6 +1186,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(), } } } @@ -1161,6 +1325,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() @@ -1181,7 +1355,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() } @@ -1196,7 +1380,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(), } @@ -1206,7 +1391,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(), } @@ -1219,6 +1405,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(), } } @@ -1227,6 +1414,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(), } } } @@ -1267,7 +1455,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() } @@ -1301,7 +1499,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(), } @@ -1310,7 +1509,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/resolution.rs b/crates/uv-distribution-types/src/resolution.rs index 7e300b454109..ba8b2ec9ec8a 100644 --- a/crates/uv-distribution-types/src/resolution.rs +++ b/crates/uv-distribution-types/src/resolution.rs @@ -243,6 +243,14 @@ impl From<&ResolvedDist> for RequirementSource { url: wheel.url.clone(), ext: DistExtension::Wheel, }, + Dist::Built(BuiltDist::GitPath(wheel)) => RequirementSource::GitPath { + url: wheel.url.clone(), + repository: wheel.git.repository().clone(), + reference: wheel.git.reference().clone(), + precise: wheel.git.precise(), + install_path: wheel.install_path.clone(), + ext: DistExtension::Wheel, + }, Dist::Source(SourceDist::Registry(sdist)) => RequirementSource::Registry { specifier: uv_pep440::VersionSpecifiers::from( uv_pep440::VersionSpecifier::equals_version(sdist.version.clone()), @@ -260,7 +268,15 @@ impl From<&ResolvedDist> for RequirementSource { ext: DistExtension::Source(sdist.ext), } } - Dist::Source(SourceDist::Git(sdist)) => RequirementSource::Git { + Dist::Source(SourceDist::GitPath(sdist)) => RequirementSource::GitPath { + url: sdist.url.clone(), + repository: sdist.git.repository().clone(), + reference: sdist.git.reference().clone(), + precise: sdist.git.precise(), + install_path: sdist.install_path.clone(), + ext: DistExtension::Source(sdist.ext), + }, + Dist::Source(SourceDist::GitDirectory(sdist)) => RequirementSource::GitDirectory { url: sdist.url.clone(), repository: sdist.git.repository().clone(), reference: sdist.git.reference().clone(), diff --git a/crates/uv-distribution-types/src/traits.rs b/crates/uv-distribution-types/src/traits.rs index 0ddf735e88e5..4b926b539eaf 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 95b52b516051..3b0a0bfc3c06 100644 --- a/crates/uv-distribution/src/distribution_database.rs +++ b/crates/uv-distribution/src/distribution_database.rs @@ -32,6 +32,7 @@ use uv_types::BuildContext; use crate::archive::Archive; use crate::locks::Locks; use crate::metadata::{ArchiveMetadata, Metadata}; +use crate::reporter::Facade; use crate::source::SourceDistributionBuilder; use crate::{Error, LocalWheel, Reporter, RequiresDist}; @@ -282,6 +283,35 @@ 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.uncached_client(&wheel.url).clone(), + self.build_context.cache().bucket(CacheBucket::Git), + self.reporter + .clone() + .map(Facade::from) + .map(|reporter| Arc::new(reporter) as _), + ) + .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.to_short_string()).root(), + wheel.filename.stem(), + ); + + let install_path = fetch.path().join(&wheel.install_path); + + self.load_wheel(&install_path, &wheel.filename, cache_entry, dist, hashes) + .await + } + BuiltDist::Path(wheel) => { let cache_entry = self.build_context.cache().entry( CacheBucket::Wheels, @@ -391,7 +421,15 @@ 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(Facade::from) + .map(|reporter| Arc::new(reporter) as _), + ) .boxed_local() }) .await; @@ -755,12 +793,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.stem())); - 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. @@ -777,7 +815,7 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> { let archive = Archive::new(self.unzip_wheel(path, wheel_entry.path()).await?, vec![]); // Write the archive pointer to the cache. - let pointer = LocalArchivePointer { + let pointer = PathArchivePointer { timestamp: modified, archive: archive.clone(), }; @@ -823,7 +861,7 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> { let archive = Archive::new(id, hashes); // Write the archive pointer to the cache. - let pointer = LocalArchivePointer { + let pointer = PathArchivePointer { timestamp: modified, archive: archive.clone(), }; @@ -996,22 +1034,22 @@ 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)?)), + Ok(cached) => Ok(Some(rmp_serde::from_slice::(&cached)?)), Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), Err(err) => Err(Error::CacheRead(err)), } } - /// Write an [`LocalArchivePointer`] to the cache. + /// Write an [`PathArchivePointer`] to the cache. pub 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 45a04d939d2e..b324fc9b33eb 100644 --- a/crates/uv-distribution/src/index/built_wheel_index.rs +++ b/crates/uv-distribution/src/index/built_wheel_index.rs @@ -1,12 +1,16 @@ use crate::index::cached_wheel::CachedWheel; -use crate::source::{HttpRevisionPointer, LocalRevisionPointer, HTTP_REVISION, LOCAL_REVISION}; +use crate::source::{ + HttpRevisionPointer, LocalRevisionPointer, RevisionHashes, HASHES, HTTP_REVISION, + LOCAL_REVISION, +}; use crate::Error; use uv_cache::{Cache, CacheBucket, CacheShard, WheelCache}; use uv_cache_info::CacheInfo; use uv_cache_key::cache_digest; use uv_configuration::ConfigSettings; use uv_distribution_types::{ - DirectUrlSourceDist, DirectorySourceDist, GitSourceDist, Hashed, PathSourceDist, + DirectUrlSourceDist, DirectorySourceDist, GitDirectorySourceDist, GitPathSourceDist, Hashed, + PathSourceDist, }; use uv_fs::symlinks; use uv_platform_tags::Tags; @@ -160,7 +164,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).is_validate() { return None; @@ -183,6 +187,37 @@ impl<'a> BuiltWheelIndex<'a> { self.find(&cache_shard) } + /// 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.to_short_string()).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 cache_shard = if self.build_configuration.is_empty() { + cache_shard + } else { + cache_shard.shard(cache_digest(self.build_configuration)) + }; + + Ok(self.find(&cache_shard)) + } + /// 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 bdddcf250aa4..e6e331007bf6 100644 --- a/crates/uv-distribution/src/index/cached_wheel.rs +++ b/crates/uv-distribution/src/index/cached_wheel.rs @@ -1,7 +1,7 @@ use std::path::Path; use crate::archive::Archive; -use crate::{HttpArchivePointer, LocalArchivePointer}; +use crate::{HttpArchivePointer, PathArchivePointer}; use uv_cache::{Cache, CacheBucket, CacheEntry}; use uv_cache_info::CacheInfo; use uv_distribution_filename::WheelFilename; @@ -125,7 +125,7 @@ impl CachedWheel { let filename = WheelFilename::from_stem(filename).ok()?; // 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 Archive { id, hashes } = pointer.into_archive(); diff --git a/crates/uv-distribution/src/lib.rs b/crates/uv-distribution/src/lib.rs index fab03bf68393..4c72599bbccc 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 adf87ba757ea..30633bfe5fcc 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -179,6 +179,7 @@ impl LoweredRequirement { Source::Git { git, subdirectory, + path, rev, tag, branch, @@ -188,6 +189,7 @@ impl LoweredRequirement { let source = git_source( &git, subdirectory.map(PathBuf::from), + path.map(PathBuf::from), rev, tag, branch, @@ -310,7 +312,7 @@ impl LoweredRequirement { uv_fs::relative_to(member.root(), git_member.fetch_root) .expect("Workspace member must be relative"); let subdirectory = uv_fs::normalize_path_buf(subdirectory); - RequirementSource::Git { + RequirementSource::GitDirectory { repository: git_member.git_source.git.repository().clone(), reference: git_member.git_source.git.reference().clone(), precise: git_member.git_source.git.precise(), @@ -416,6 +418,7 @@ impl LoweredRequirement { Source::Git { git, subdirectory, + path, rev, tag, branch, @@ -425,6 +428,7 @@ impl LoweredRequirement { let source = git_source( &git, subdirectory.map(PathBuf::from), + path.map(PathBuf::from), rev, tag, branch, @@ -572,6 +576,7 @@ impl std::fmt::Display for SourceKind { fn git_source( git: &Url, subdirectory: Option, + path: Option, rev: Option, tag: Option, branch: Option, @@ -595,17 +600,40 @@ fn git_source( .ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.clone()))?; url.set_fragment(Some(&format!("subdirectory={subdirectory}"))); } + if let Some(path) = path.as_ref() { + let path = path + .to_str() + .ok_or_else(|| LoweringError::NonUtf8Path(path.clone()))?; + url.set_fragment(Some(&format!("path={path}"))); + } let url = VerbatimUrl::from_url(url); let repository = git.clone(); - Ok(RequirementSource::Git { - url, - repository, - reference, - precise: None, - 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, + repository, + reference, + precise: None, + install_path: path, + ext, + }) + } else { + Ok(RequirementSource::GitDirectory { + url, + repository, + reference, + precise: None, + subdirectory, + }) + } } /// Convert a URL source into a [`RequirementSource`]. @@ -716,12 +744,13 @@ fn path_source( } else { install_path.extension().is_none() }; - if is_dir { - if let Some(git_member) = git_member { + + if let Some(git_member) = git_member { + return if is_dir { let subdirectory = uv_fs::relative_to(install_path, git_member.fetch_root) .expect("Workspace member must be relative"); let subdirectory = uv_fs::normalize_path_buf(subdirectory); - return Ok(RequirementSource::Git { + Ok(RequirementSource::GitDirectory { repository: git_member.git_source.git.repository().clone(), reference: git_member.git_source.git.reference().clone(), precise: git_member.git_source.git.precise(), @@ -731,9 +760,24 @@ fn path_source( Some(subdirectory) }, url, - }); - } + }) + } else { + let install_path = uv_fs::relative_to(install_path, git_member.fetch_root) + .expect("Workspace member must be relative"); + let install_path = uv_fs::normalize_path_buf(install_path); + Ok(RequirementSource::GitPath { + url, + repository: git_member.git_source.git.repository().clone(), + reference: git_member.git_source.git.reference().clone(), + precise: git_member.git_source.git.precise(), + ext: DistExtension::from_path(&install_path) + .map_err(|err| ParsedUrlError::MissingExtensionPath(path.to_path_buf(), err))?, + install_path, + }) + }; + } + if is_dir { if editable { Ok(RequirementSource::Directory { install_path, @@ -763,10 +807,6 @@ 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 editable { return Err(LoweringError::EditableFile(url.to_string())); } diff --git a/crates/uv-distribution/src/metadata/mod.rs b/crates/uv-distribution/src/metadata/mod.rs index f5dac8830f3b..f00416ddf23c 100644 --- a/crates/uv-distribution/src/metadata/mod.rs +++ b/crates/uv-distribution/src/metadata/mod.rs @@ -4,7 +4,7 @@ use std::path::Path; use thiserror::Error; use uv_configuration::{LowerBound, SourceStrategy}; -use uv_distribution_types::{GitSourceUrl, IndexLocations}; +use uv_distribution_types::{GitDirectorySourceUrl, IndexLocations}; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pypi_types::{HashDigest, ResolutionMetadata}; @@ -153,5 +153,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 227b2f2fca53..9f0cc5f870fa 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -28,11 +28,12 @@ use uv_client::{ use uv_configuration::{BuildKind, BuildOutput, SourceStrategy}; use uv_distribution_filename::{EggInfoFilename, SourceDistExtension, WheelFilename}; use uv_distribution_types::{ - BuildableSource, DirectorySourceUrl, FileLocation, GitSourceUrl, HashPolicy, Hashed, - PathSourceUrl, SourceDist, SourceUrl, + BuildableSource, DirectorySourceUrl, FileLocation, GitDirectorySourceUrl, GitPathSourceUrl, + HashPolicy, Hashed, PathSourceUrl, SourceDist, SourceUrl, }; use uv_extract::hash::Hasher; use uv_fs::{rename_with_retry, write_atomic, LockedFile}; +use uv_git::Fetch; use uv_metadata::read_archive_metadata; use uv_normalize::PackageName; use uv_pep440::{release_specifiers_to_ranges, Version}; @@ -56,6 +57,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"; @@ -160,8 +164,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? } @@ -205,8 +220,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? } @@ -298,8 +318,18 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .boxed_local() .await? } - BuildableSource::Dist(SourceDist::Git(dist)) => { - self.git_metadata(source, &GitSourceUrl::from(dist), hashes, client) + BuildableSource::Dist(SourceDist::GitDirectory(dist)) => { + self.git_source_tree_metadata( + source, + &GitDirectorySourceUrl::from(dist), + hashes, + client, + ) + .boxed_local() + .await? + } + BuildableSource::Dist(SourceDist::GitPath(dist)) => { + self.git_archive_metadata(source, &GitPathSourceUrl::from(dist), hashes, client) .boxed_local() .await? } @@ -336,8 +366,13 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .boxed_local() .await? } - BuildableSource::Url(SourceUrl::Git(resource)) => { - self.git_metadata(source, resource, hashes, client) + BuildableSource::Url(SourceUrl::GitDirectory(resource)) => { + self.git_source_tree_metadata(source, resource, hashes, client) + .boxed_local() + .await? + } + BuildableSource::Url(SourceUrl::GitPath(resource)) => { + self.git_archive_metadata(source, resource, hashes, client) .boxed_local() .await? } @@ -1297,11 +1332,279 @@ 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 create unzip, 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( + async fn git_archive( &self, source: &BuildableSource<'_>, - resource: &GitSourceUrl<'_>, + resource: &GitPathSourceUrl<'_>, + tags: &Tags, + hashes: HashPolicy<'_>, + client: &ManagedClient<'_>, + ) -> Result { + // Fetch the Git repository. + let fetch = self + .build_context + .git() + .fetch( + resource.git, + client.unmanaged.uncached_client(resource.url).clone(), + self.build_context.cache().bucket(CacheBucket::Git), + self.reporter + .clone() + .map(Facade::from) + .map(|reporter| Arc::new(reporter) as _), + ) + .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.to_short_string()).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(), + )); + } + + // If there are build settings, we need to scope to a cache shard. + let config_settings = self.build_context.config_settings(); + let cache_shard = if config_settings.is_empty() { + cache_shard + } else { + cache_shard.shard(cache_digest(config_settings)) + }; + + // If the cache contains a compatible wheel, return it. + if let Some(built_wheel) = BuiltWheelMetadata::find_in_cache(tags, &cache_shard) + .filter(|built_wheel| built_wheel.matches(source.name(), source.version())) + { + return Ok(built_wheel); + } + + // Otherwise, we need to build a wheel. + let task = self + .reporter + .as_ref() + .map(|reporter| reporter.on_build_start(source)); + + let source_entry = cache_shard.entry(SOURCE); + + let (disk_filename, filename, metadata) = self + .build_distribution( + source, + source_entry.path(), + None, + &cache_shard, + SourceStrategy::Disabled, + ) + .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), + target: cache_shard.join(filename.stem()), + filename, + hashes: revision.into_hashes(), + cache_info: CacheInfo::default(), + }) + } + + /// 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.uncached_client(resource.url).clone(), + self.build_context.cache().bucket(CacheBucket::Git), + self.reporter + .clone() + .map(Facade::from) + .map(|reporter| Arc::new(reporter) as _), + ) + .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.to_short_string()).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. + if let Some(metadata) = + Self::read_static_metadata(source, source_entry.path(), None).await? + { + return Ok(ArchiveMetadata { + metadata: Metadata::from_metadata23(metadata), + hashes: revision.into_hashes(), + }); + } + + // If the cache contains compatible metadata, return it. + let metadata_entry = cache_shard.entry(METADATA); + if let Some(metadata) = CachedMetadata::read(&metadata_entry) + .await? + .filter(|metadata| 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(), + }); + } + + // If the backend supports `prepare_metadata_for_build_wheel`, use it. + if let Some(metadata) = self + .build_metadata(source, source_entry.path(), None, SourceStrategy::Disabled) + .boxed_local() + .await? + { + // 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, we need to scope to a cache shard. + let config_settings = self.build_context.config_settings(); + let cache_shard = if config_settings.is_empty() { + cache_shard + } else { + cache_shard.shard(cache_digest(config_settings)) + }; + + // 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, + SourceStrategy::Disabled, + ) + .await?; + + if let Some(task) = task { + if let Some(reporter) = self.reporter.as_ref() { + reporter.on_build_complete(source, task); + } + } + + // 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_source_tree( + &self, + source: &BuildableSource<'_>, + resource: &GitDirectorySourceUrl<'_>, tags: &Tags, hashes: HashPolicy<'_>, client: &ManagedClient<'_>, @@ -1319,7 +1622,10 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { resource.git, client.unmanaged.uncached_client(resource.url).clone(), self.build_context.cache().bucket(CacheBucket::Git), - self.reporter.clone().map(Facade::from), + self.reporter + .clone() + .map(Facade::from) + .map(|reporter| Arc::new(reporter) as _), ) .await?; @@ -1396,10 +1702,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<'_>, ) -> Result { @@ -1416,7 +1722,10 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { resource.git, client.unmanaged.uncached_client(resource.url).clone(), self.build_context.cache().bucket(CacheBucket::Git), - self.reporter.clone().map(Facade::from), + self.reporter + .clone() + .map(Facade::from) + .map(|reporter| Arc::new(reporter) as _), ) .await?; @@ -1585,25 +1894,59 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { client: &ManagedClient<'_>, ) -> Result<(), Error> { match source { - BuildableSource::Dist(SourceDist::Git(source)) => { + BuildableSource::Dist(SourceDist::GitPath(source)) => { self.build_context .git() .fetch( &source.git, client.unmanaged.uncached_client(&source.url).clone(), self.build_context.cache().bucket(CacheBucket::Git), - self.reporter.clone().map(Facade::from), + self.reporter + .clone() + .map(Facade::from) + .map(|reporter| Arc::new(reporter) as _), + ) + .await?; + } + BuildableSource::Dist(SourceDist::GitDirectory(source)) => { + self.build_context + .git() + .fetch( + &source.git, + client.unmanaged.uncached_client(&source.url).clone(), + self.build_context.cache().bucket(CacheBucket::Git), + self.reporter + .clone() + .map(Facade::from) + .map(|reporter| Arc::new(reporter) as _), + ) + .await?; + } + BuildableSource::Url(SourceUrl::GitPath(source)) => { + self.build_context + .git() + .fetch( + source.git, + client.unmanaged.uncached_client(source.url).clone(), + self.build_context.cache().bucket(CacheBucket::Git), + self.reporter + .clone() + .map(Facade::from) + .map(|reporter| Arc::new(reporter) as _), ) .await?; } - BuildableSource::Url(SourceUrl::Git(source)) => { + BuildableSource::Url(SourceUrl::GitDirectory(source)) => { self.build_context .git() .fetch( source.git, client.unmanaged.uncached_client(source.url).clone(), self.build_context.cache().bucket(CacheBucket::Git), - self.reporter.clone().map(Facade::from), + self.reporter + .clone() + .map(Facade::from) + .map(|reporter| Arc::new(reporter) as _), ) .await?; } @@ -1816,7 +2159,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)); @@ -2317,6 +2660,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) -> Vec { + self.hashes + } +} + +impl Hashed for RevisionHashes { + fn hashes(&self) -> &[HashDigest] { + &self.hashes + } +} + /// Read the [`ResolutionMetadata`] by combining a source distribution's `PKG-INFO` file with a /// `requires.txt`. /// diff --git a/crates/uv-git/src/resolver.rs b/crates/uv-git/src/resolver.rs index e0636ec243d3..93bdf4877535 100644 --- a/crates/uv-git/src/resolver.rs +++ b/crates/uv-git/src/resolver.rs @@ -38,57 +38,13 @@ impl GitResolver { self.0.get(reference) } - /// Resolve a Git URL to a specific commit. - pub async fn resolve( - &self, - url: &GitUrl, - client: ClientWithMiddleware, - cache: PathBuf, - reporter: Option, - ) -> Result { - debug!("Resolving source distribution from Git: {url}"); - - let reference = RepositoryReference::from(url); - - // If we know the precise commit already, return it. - if let Some(precise) = self.get(&reference) { - return Ok(*precise); - } - - // Avoid races between different processes, too. - let lock_dir = cache.join("locks"); - fs::create_dir_all(&lock_dir).await?; - let repository_url = RepositoryUrl::new(url.repository()); - let _lock = LockedFile::acquire( - lock_dir.join(cache_digest(&repository_url)), - &repository_url, - ) - .await?; - - // Fetch the Git repository. - let source = if let Some(reporter) = reporter { - GitSource::new(url.clone(), client, cache).with_reporter(reporter) - } else { - GitSource::new(url.clone(), client, cache) - }; - let precise = tokio::task::spawn_blocking(move || source.resolve()) - .await? - .map_err(GitResolverError::Git)?; - - // Insert the resolved URL into the in-memory cache. This ensures that subsequent fetches - // resolve to the same precise commit. - self.insert(reference, precise); - - Ok(precise) - } - /// Fetch a remote Git repository. pub async fn fetch( &self, url: &GitUrl, client: ClientWithMiddleware, cache: PathBuf, - reporter: Option, + reporter: Option>, ) -> Result { debug!("Fetching source distribution from Git: {url}"); diff --git a/crates/uv-git/src/source.rs b/crates/uv-git/src/source.rs index 5b475af90fd6..0ef50faa1310 100644 --- a/crates/uv-git/src/source.rs +++ b/crates/uv-git/src/source.rs @@ -2,11 +2,11 @@ //! Cargo is dual-licensed under either Apache 2.0 or MIT, at the user's choice. //! Source: -use std::borrow::Cow; -use std::path::{Path, PathBuf}; - use anyhow::Result; use reqwest_middleware::ClientWithMiddleware; +use std::borrow::Cow; +use std::path::{Path, PathBuf}; +use std::sync::Arc; use tracing::{debug, instrument}; use url::Url; @@ -24,7 +24,7 @@ pub struct GitSource { /// The path to the Git source database. cache: PathBuf, /// The reporter to use for this source. - reporter: Option>, + reporter: Option>, } impl GitSource { @@ -44,9 +44,9 @@ impl GitSource { /// Set the [`Reporter`] to use for this `GIt` source. #[must_use] - pub fn with_reporter(self, reporter: impl Reporter + 'static) -> Self { + pub fn with_reporter(self, reporter: Arc) -> Self { Self { - reporter: Some(Box::new(reporter)), + reporter: Some(reporter), ..self } } diff --git a/crates/uv-installer/src/plan.rs b/crates/uv-installer/src/plan.rs index 0ef3c08a86a2..07de55173d3f 100644 --- a/crates/uv-installer/src/plan.rs +++ b/crates/uv-installer/src/plan.rs @@ -5,7 +5,7 @@ use uv_cache::{Cache, CacheBucket, WheelCache}; use uv_cache_info::{CacheInfo, Timestamp}; use uv_configuration::{BuildOptions, ConfigSettings, Reinstall}; use uv_distribution::{ - BuiltWheelIndex, HttpArchivePointer, LocalArchivePointer, RegistryWheelIndex, + BuiltWheelIndex, HttpArchivePointer, PathArchivePointer, RegistryWheelIndex, }; use uv_distribution_types::{ BuiltDist, CachedDirectUrlDist, CachedDist, Dist, Error, Hashed, IndexLocations, InstalledDist, @@ -135,7 +135,6 @@ impl<'a> Planner<'a> { Some(&entry.dist) }) { debug!("Requirement already cached: {distribution}"); - // STOPSHIP(charlie): If these are mismatched, skip and warn. cached.push(CachedDist::Registry(distribution.clone())); continue; } @@ -177,7 +176,6 @@ impl<'a> Planner<'a> { ); debug!("URL wheel requirement already cached: {cached_dist}"); - // STOPSHIP(charlie): If these are mismatched, skip and warn. cached.push(CachedDist::Url(cached_dist)); continue; } @@ -212,7 +210,7 @@ impl<'a> Planner<'a> { ) .entry(format!("{}.rev", wheel.filename.stem())); - if let Some(pointer) = LocalArchivePointer::read_from(&cache_entry)? { + if let Some(pointer) = PathArchivePointer::read_from(&cache_entry)? { let timestamp = Timestamp::from_path(&wheel.install_path)?; if pointer.is_up_to_date(timestamp) { let cache_info = pointer.to_cache_info(); @@ -233,6 +231,50 @@ 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.to_short_string()).root(), + ) + .entry(format!("{}.rev", wheel.filename.stem())); + + if let Some(pointer) = PathArchivePointer::read_from(&cache_entry)? { + let cache_info = pointer.to_cache_info(); + let archive = pointer.into_archive(); + if archive.satisfies(hasher.get(dist)) { + let cached_dist = CachedDirectUrlDist::from_url( + wheel.filename.clone(), + wheel.url.clone(), + archive.hashes, + cache_info, + cache.archive(&archive.id), + ); + + 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 { @@ -275,10 +317,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_url_dist(sdist.url.clone()); + 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_url_dist(sdist.url.clone()); 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 329fadc5660d..fe3eae9265ae 100644 --- a/crates/uv-installer/src/satisfies.rs +++ b/crates/uv-installer/src/satisfies.rs @@ -94,7 +94,7 @@ impl RequirementSatisfaction { // Otherwise, assume the requirement is up-to-date. Ok(Self::Satisfied) } - RequirementSource::Git { + RequirementSource::GitDirectory { url: _, repository: requested_repository, reference: requested_reference, @@ -114,6 +114,7 @@ impl RequirementSatisfaction { commit_id: _, }, subdirectory: installed_subdirectory, + path: None, } = direct_url.as_ref() else { return Ok(Self::Mismatch); @@ -149,6 +150,63 @@ impl RequirementSatisfaction { Ok(Self::Satisfied) } + RequirementSource::GitPath { + url: _, + repository: requested_repository, + reference: requested_reference, + precise: requested_precise, + install_path: requested_path, + ext: _, + } => { + let InstalledDist::Url(InstalledDirectUrlDist { direct_url, .. }) = &distribution + else { + return Ok(Self::Mismatch); + }; + let DirectUrl::VcsUrl { + url: installed_url, + vcs_info: + VcsInfo { + vcs: VcsKind::Git, + requested_revision: installed_reference, + commit_id: _, + }, + subdirectory: None, + path: Some(installed_path), + } = direct_url.as_ref() + else { + return Ok(Self::Mismatch); + }; + + if requested_path != installed_path { + debug!( + "Path mismatch: {:?} vs. {:?}", + installed_path, requested_path + ); + return Ok(Self::Mismatch); + } + + if !RepositoryUrl::parse(installed_url).is_ok_and(|installed_url| { + installed_url == RepositoryUrl::new(requested_repository) + }) { + debug!( + "Repository mismatch: {:?} vs. {:?}", + installed_url, requested_repository + ); + return Ok(Self::Mismatch); + } + + if installed_reference.as_deref() != requested_reference.as_str() + && installed_reference != &requested_precise.map(|git_sha| git_sha.to_string()) + { + debug!( + "Reference mismatch: {:?} vs. {:?} and {:?}", + installed_reference, requested_reference, requested_precise + ); + return Ok(Self::OutOfDate); + } + + Ok(Self::Satisfied) + } 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 da2fbc65fb31..2da3216ecc6b 100644 --- a/crates/uv-pypi-types/src/direct_url.rs +++ b/crates/uv-pypi-types/src/direct_url.rs @@ -38,6 +38,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, }, } @@ -108,6 +110,7 @@ impl TryFrom<&DirectUrl> for Url { url, vcs_info, subdirectory, + path, } => { let mut url = Self::parse(&format!("{}+{}", vcs_info.vcs, url))?; if let Some(commit_id) = &vcs_info.commit_id { @@ -118,6 +121,9 @@ impl TryFrom<&DirectUrl> for Url { if let Some(subdirectory) = subdirectory { url.set_fragment(Some(&format!("subdirectory={}", subdirectory.display()))); } + if let Some(path) = path { + url.set_fragment(Some(&format!("path={}", path.display()))); + } Ok(url) } } diff --git a/crates/uv-pypi-types/src/parsed_url.rs b/crates/uv-pypi-types/src/parsed_url.rs index 4f4088de288a..7893b1a9485c 100644 --- a/crates/uv-pypi-types/src/parsed_url.rs +++ b/crates/uv-pypi-types/src/parsed_url.rs @@ -164,8 +164,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), @@ -231,19 +233,20 @@ impl ParsedDirectoryUrl { } } -/// A Git repository URL. +/// A Git repository URL, pointing to the repository root or a subdirectory defining a source tree. /// /// Examples: /// * `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, + /// The subdirectory within the repository to install. 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( repository: Url, reference: GitReference, @@ -259,7 +262,7 @@ impl ParsedGitUrl { } } -impl TryFrom for ParsedGitUrl { +impl TryFrom for ParsedGitDirectoryUrl { type Error = ParsedUrlError; /// Supports URLs with and without the `git+` prefix. @@ -280,6 +283,68 @@ 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( + repository: Url, + reference: GitReference, + precise: Option, + install_path: PathBuf, + ext: DistExtension, + ) -> Self { + let url = if let Some(precise) = precise { + GitUrl::from_commit(repository, reference, precise) + } else { + GitUrl::from_reference(repository, reference) + }; + 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: Url) -> 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 = Url::parse(url).map_err(|err| ParsedUrlError::UrlParse(url.to_string(), err))?; + let url = GitUrl::try_from(url) + .map_err(|err| ParsedUrlError::GitShaParse(url_in.to_string(), err))?; + Ok(Self { + url, + install_path, + ext, + }) + } +} + /// A URL to a source or built archive. /// /// Examples: @@ -324,10 +389,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 { @@ -338,13 +403,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: Url) -> 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(), @@ -370,7 +452,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() @@ -408,7 +490,8 @@ impl TryFrom<&ParsedUrl> for DirectUrl { match value { ParsedUrl::Path(value) => Self::try_from(value), ParsedUrl::Directory(value) => Self::try_from(value), - ParsedUrl::Git(value) => Self::try_from(value), + ParsedUrl::GitPath(value) => Self::try_from(value), + ParsedUrl::GitDirectory(value) => Self::try_from(value), ParsedUrl::Archive(value) => Self::try_from(value), } } @@ -457,10 +540,10 @@ impl TryFrom<&ParsedArchiveUrl> for DirectUrl { } } -impl TryFrom<&ParsedGitUrl> for DirectUrl { +impl TryFrom<&ParsedGitDirectoryUrl> for DirectUrl { type Error = ParsedUrlError; - fn try_from(value: &ParsedGitUrl) -> Result { + fn try_from(value: &ParsedGitDirectoryUrl) -> Result { Ok(Self::VcsUrl { url: value.url.repository().to_string(), vcs_info: VcsInfo { @@ -469,6 +552,24 @@ impl TryFrom<&ParsedGitUrl> for DirectUrl { requested_revision: value.url.reference().as_str().map(ToString::to_string), }, subdirectory: value.subdirectory.clone(), + path: None, + }) + } +} + +impl TryFrom<&ParsedGitPathUrl> for DirectUrl { + type Error = ParsedUrlError; + + fn try_from(value: &ParsedGitPathUrl) -> Result { + Ok(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), + }, + subdirectory: None, + path: Some(value.install_path.clone()), }) } } @@ -478,7 +579,8 @@ impl From for Url { 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(), } } @@ -506,8 +608,17 @@ impl From for Url { } } -impl From for Url { - fn from(value: ParsedGitUrl) -> Self { +impl From for Url { + fn from(value: ParsedGitPathUrl) -> Self { + let mut url = Self::parse(&format!("{}{}", "git+", Self::from(value.url).as_str())) + .expect("Git URL is invalid"); + url.set_fragment(Some(&format!("path={}", value.install_path.display()))); + url + } +} + +impl From for Url { + fn from(value: ParsedGitDirectoryUrl) -> Self { let mut url = Self::parse(&format!("{}{}", "git+", Self::from(value.url).as_str())) .expect("Git URL is invalid"); if let Some(subdirectory) = value.subdirectory { diff --git a/crates/uv-pypi-types/src/requirement.rs b/crates/uv-pypi-types/src/requirement.rs index 8ada1bfa41b7..2670aced0326 100644 --- a/crates/uv-pypi-types/src/requirement.rs +++ b/crates/uv-pypi-types/src/requirement.rs @@ -16,8 +16,8 @@ use uv_pep508::{ }; use crate::{ - ConflictItem, Hashes, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, - ParsedUrl, ParsedUrlError, VerbatimParsedUrl, + ConflictItem, Hashes, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitDirectoryUrl, + ParsedGitPathUrl, ParsedPathUrl, ParsedUrl, ParsedUrlError, VerbatimParsedUrl, }; #[derive(Debug, Error)] @@ -192,7 +192,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)), }, @@ -225,7 +226,7 @@ impl From for uv_pep508::Requirement { }), verbatim: url, })), - RequirementSource::Git { + RequirementSource::GitDirectory { repository, reference, precise, @@ -238,13 +239,35 @@ impl From for uv_pep508::Requirement { GitUrl::from_reference(repository, reference) }; Some(VersionOrUrl::Url(VerbatimParsedUrl { - parsed_url: ParsedUrl::Git(ParsedGitUrl { + parsed_url: ParsedUrl::GitDirectory(ParsedGitDirectoryUrl { url: git_url, subdirectory, }), verbatim: url, })) } + RequirementSource::GitPath { + repository, + reference, + precise, + install_path, + ext, + url, + } => { + let git_url = if let Some(precise) = precise { + GitUrl::from_commit(repository, reference, precise) + } else { + GitUrl::from_reference(repository, reference) + }; + Some(VersionOrUrl::Url(VerbatimParsedUrl { + parsed_url: ParsedUrl::GitPath(ParsedGitPathUrl { + url: git_url, + install_path, + ext, + }), + verbatim: url, + })) + } RequirementSource::Path { install_path, ext, @@ -334,7 +357,7 @@ impl Display for Requirement { RequirementSource::Url { url, .. } => { write!(f, " @ {url}")?; } - RequirementSource::Git { + RequirementSource::GitDirectory { url: _, repository, reference, @@ -349,6 +372,20 @@ impl Display for Requirement { writeln!(f, "#subdirectory={}", subdirectory.display())?; } } + RequirementSource::GitPath { + url: _, + repository, + reference, + precise: _, + install_path, + ext: _, + } => { + write!(f, " @ git+{repository}")?; + if let Some(reference) = reference.as_str() { + write!(f, "@{reference}")?; + } + writeln!(f, "#path={}", install_path.display())?; + } RequirementSource::Path { url, .. } => { write!(f, " @ {url}")?; } @@ -400,7 +437,7 @@ pub enum RequirementSource { url: VerbatimUrl, }, /// A remote Git repository, over either HTTPS or SSH. - Git { + GitDirectory { /// The repository URL (without the `git+` prefix). repository: Url, /// Optionally, the revision, tag, or branch to use. @@ -413,6 +450,22 @@ pub enum RequirementSource { /// `git+:///@#subdirectory=`. url: VerbatimUrl, }, + /// A remote Git repository, over either HTTPS or SSH. + GitPath { + /// The repository URL (without the `git+` prefix). + repository: Url, + /// Optionally, the revision, tag, or branch to use. + reference: GitReference, + /// The precise commit to use, if known. + precise: Option, + /// 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). @@ -456,13 +509,21 @@ impl RequirementSource { r#virtual: directory.r#virtual, url, }, - ParsedUrl::Git(git) => RequirementSource::Git { + ParsedUrl::GitDirectory(git) => RequirementSource::GitDirectory { url, repository: git.url.repository().clone(), reference: git.url.reference().clone(), precise: git.url.precise(), subdirectory: git.subdirectory, }, + ParsedUrl::GitPath(git) => RequirementSource::GitPath { + url, + repository: git.url.repository().clone(), + reference: git.url.reference().clone(), + precise: git.url.precise(), + install_path: git.install_path.clone(), + ext: git.ext, + }, ParsedUrl::Archive(archive) => RequirementSource::Url { url, location: archive.url, @@ -521,14 +582,14 @@ impl RequirementSource { )), verbatim: url.clone(), }), - Self::Git { + Self::GitDirectory { repository, reference, precise, subdirectory, url, } => Some(VerbatimParsedUrl { - parsed_url: ParsedUrl::Git(ParsedGitUrl::from_source( + parsed_url: ParsedUrl::GitDirectory(ParsedGitDirectoryUrl::from_source( repository.clone(), reference.clone(), *precise, @@ -536,6 +597,23 @@ impl RequirementSource { )), verbatim: url.clone(), }), + Self::GitPath { + repository, + reference, + precise, + install_path, + ext, + url, + } => Some(VerbatimParsedUrl { + parsed_url: ParsedUrl::GitPath(ParsedGitPathUrl::from_source( + repository.clone(), + reference.clone(), + *precise, + install_path.clone(), + *ext, + )), + verbatim: url.clone(), + }), } } @@ -551,9 +629,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()?)), } } @@ -566,30 +646,34 @@ 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, } } /// If the source is the registry, return the version specifiers pub fn version_specifiers(&self) -> Option<&VersionSpecifiers> { match self { - RequirementSource::Registry { specifier, .. } => Some(specifier), - RequirementSource::Url { .. } - | RequirementSource::Git { .. } - | RequirementSource::Path { .. } - | RequirementSource::Directory { .. } => None, + Self::Registry { specifier, .. } => Some(specifier), + 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 { - RequirementSource::Registry { .. } - | RequirementSource::Url { .. } - | RequirementSource::Git { .. } => Ok(self), - RequirementSource::Path { + Self::Registry { .. } + | Self::Url { .. } + | Self::GitPath { .. } + | Self::GitDirectory { .. } => Ok(self), + Self::Path { install_path, ext, url, @@ -599,7 +683,7 @@ impl RequirementSource { ext, url, }), - RequirementSource::Directory { + Self::Directory { install_path, editable, r#virtual, @@ -632,7 +716,7 @@ impl Display for RequirementSource { Self::Url { url, .. } => { write!(f, " {url}")?; } - Self::Git { + Self::GitDirectory { url: _, repository, reference, @@ -647,6 +731,20 @@ impl Display for RequirementSource { writeln!(f, "#subdirectory={}", subdirectory.display())?; } } + Self::GitPath { + url: _, + repository, + reference, + precise: _, + install_path, + ext: _, + } => { + write!(f, " git+{repository}")?; + if let Some(reference) = reference.as_str() { + write!(f, "@{reference}")?; + } + writeln!(f, "#path={}", install_path.display())?; + } Self::Path { url, .. } => { write!(f, "{url}")?; } @@ -711,7 +809,7 @@ impl From for RequirementSourceWire { url: location, subdirectory: subdirectory.map(PortablePathBuf::from), }, - RequirementSource::Git { + RequirementSource::GitDirectory { repository, reference, precise, @@ -768,6 +866,58 @@ impl From for RequirementSourceWire { git: url.to_string(), } } + RequirementSource::GitPath { + repository, + reference, + precise, + install_path, + ext: _, + url: _, + } => { + let mut url = repository; + + // Redact the credentials. + redact_credentials(&mut url); + + // 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 reference { + GitReference::Branch(branch) => { + url.query_pairs_mut() + .append_pair("branch", branch.to_string().as_str()); + } + GitReference::Tag(tag) => { + url.query_pairs_mut() + .append_pair("tag", tag.to_string().as_str()); + } + GitReference::ShortCommit(rev) + | GitReference::BranchOrTag(rev) + | GitReference::BranchOrTagOrCommit(rev) + | GitReference::NamedRef(rev) + | GitReference::FullCommit(rev) => { + url.query_pairs_mut() + .append_pair("rev", rev.to_string().as_str()); + } + GitReference::DefaultBranch => {} + } + + // Put the precise commit in the fragment. + if let Some(precise) = precise { + url.set_fragment(Some(&precise.to_string())); + } + + Self::Git { + git: url.to_string(), + } + } RequirementSource::Path { install_path, ext: _, @@ -818,6 +968,7 @@ impl TryFrom for RequirementSource { let mut reference = GitReference::DefaultBranch; let mut subdirectory: Option = None; + let mut path: Option = None; for (key, val) in repository.query_pairs() { match &*key { "tag" => reference = GitReference::Tag(val.into_owned()), @@ -826,6 +977,9 @@ impl TryFrom for RequirementSource { "subdirectory" => { subdirectory = Some(PortablePathBuf::from(val.as_ref())); } + "path" => { + path = Some(PortablePathBuf::from(val.as_ref())); + } _ => continue, }; } @@ -847,15 +1001,31 @@ impl TryFrom for RequirementSource { if let Some(subdirectory) = subdirectory.as_ref() { url.set_fragment(Some(&format!("subdirectory={subdirectory}"))); } + if let Some(path) = path.as_ref() { + url.set_fragment(Some(&format!("path={path}"))); + } let url = VerbatimUrl::from_url(url); - Ok(Self::Git { - repository, - reference, - precise, - subdirectory: subdirectory.map(PathBuf::from), - url, - }) + if let Some(install_path) = path.map(PathBuf::from) { + Ok(Self::GitPath { + repository, + reference, + precise, + ext: DistExtension::from_path(install_path.as_path()).map_err(|err| { + ParsedUrlError::MissingExtensionPath(install_path.clone(), err) + })?, + install_path, + url, + }) + } else { + Ok(Self::GitDirectory { + repository, + reference, + precise, + subdirectory: subdirectory.map(PathBuf::from), + url, + }) + } } RequirementSourceWire::Direct { url, subdirectory } => { let location = url.clone(); diff --git a/crates/uv-requirements-txt/src/requirement.rs b/crates/uv-requirements-txt/src/requirement.rs index 308c9432de07..c627e988cb23 100644 --- a/crates/uv-requirements-txt/src/requirement.rs +++ b/crates/uv-requirements-txt/src/requirement.rs @@ -81,7 +81,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())) } }; @@ -106,7 +109,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 b597880268ae..272608df80b0 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 @@ -36,8 +36,8 @@ RequirementsTxt { version_or_url: Some( Url( VerbatimParsedUrl { - parsed_url: Git( - ParsedGitUrl { + parsed_url: GitDirectory( + ParsedGitDirectoryUrl { url: GitUrl { repository: Url { 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 b597880268ae..272608df80b0 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 @@ -36,8 +36,8 @@ RequirementsTxt { version_or_url: Some( Url( VerbatimParsedUrl { - parsed_url: Git( - ParsedGitUrl { + parsed_url: GitDirectory( + ParsedGitDirectoryUrl { url: GitUrl { repository: Url { scheme: "https", diff --git a/crates/uv-requirements/src/lib.rs b/crates/uv-requirements/src/lib.rs index adb342e133c6..06fe06705987 100644 --- a/crates/uv-requirements/src/lib.rs +++ b/crates/uv-requirements/src/lib.rs @@ -5,7 +5,7 @@ pub use crate::sources::*; pub use crate::specification::*; pub use crate::unnamed::*; -use uv_distribution_types::{Dist, DistErrorKind, GitSourceDist, SourceDist}; +use uv_distribution_types::{Dist, DistErrorKind}; use uv_git::GitUrl; use uv_pypi_types::{Requirement, RequirementSource}; @@ -61,7 +61,7 @@ pub(crate) fn required_dist( subdirectory.clone(), *ext, )?, - RequirementSource::Git { + RequirementSource::GitDirectory { repository, reference, precise, @@ -73,12 +73,33 @@ pub(crate) fn required_dist( } else { GitUrl::from_reference(repository.clone(), reference.clone()) }; - Dist::Source(SourceDist::Git(GitSourceDist { - name: requirement.name.clone(), - git: Box::new(git_url), - subdirectory: subdirectory.clone(), - url: url.clone(), - })) + Dist::from_git_directory_url( + requirement.name.clone(), + url.clone(), + git_url, + subdirectory.clone(), + )? + } + RequirementSource::GitPath { + repository, + reference, + precise, + install_path, + ext, + url, + } => { + let git_url = if let Some(precise) = precise { + GitUrl::from_commit(repository.clone(), reference.clone(), *precise) + } else { + GitUrl::from_reference(repository.clone(), reference.clone()) + }; + Dist::from_git_path_url( + requirement.name.clone(), + url.clone(), + git_url, + install_path.clone(), + *ext, + )? } RequirementSource::Path { install_path, diff --git a/crates/uv-requirements/src/unnamed.rs b/crates/uv-requirements/src/unnamed.rs index 252cc368e1d5..742c31fe51e6 100644 --- a/crates/uv-requirements/src/unnamed.rs +++ b/crates/uv-requirements/src/unnamed.rs @@ -13,8 +13,8 @@ use crate::Error; use uv_distribution::{DistributionDatabase, Reporter}; use uv_distribution_filename::{DistExtension, SourceDistFilename, WheelFilename}; use uv_distribution_types::{ - BuildableSource, DirectSourceUrl, DirectorySourceUrl, GitSourceUrl, PathSourceUrl, - RemoteSource, SourceUrl, VersionId, + BuildableSource, DirectSourceUrl, DirectorySourceUrl, GitDirectorySourceUrl, GitPathSourceUrl, + PathSourceUrl, RemoteSource, SourceUrl, VersionId, }; use uv_normalize::PackageName; use uv_pep508::{UnnamedRequirement, VersionOrUrl}; @@ -264,11 +264,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/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 04a29f98b2fe..f23e30549515 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -33,19 +33,20 @@ use uv_distribution_filename::{ }; use uv_distribution_types::{ BuiltDist, DependencyMetadata, DirectUrlBuiltDist, DirectUrlSourceDist, DirectorySourceDist, - Dist, DistributionMetadata, FileLocation, GitSourceDist, IndexLocations, IndexUrl, Name, - PathBuiltDist, PathSourceDist, RegistryBuiltDist, RegistryBuiltWheel, RegistrySourceDist, - RemoteSource, ResolvedDist, StaticMetadata, ToUrlError, UrlString, + Dist, DistributionMetadata, FileLocation, GitDirectorySourceDist, GitPathBuiltDist, + GitPathSourceDist, IndexLocations, IndexUrl, Name, PathBuiltDist, PathSourceDist, + RegistryBuiltDist, RegistryBuiltWheel, RegistrySourceDist, RemoteSource, ResolvedDist, + StaticMetadata, ToUrlError, UrlString, }; use uv_fs::{relative_to, PortablePath, PortablePathBuf}; -use uv_git::{GitReference, GitSha, RepositoryReference, ResolvedRepositoryReference}; +use uv_git::{GitReference, GitSha, GitUrl, RepositoryReference, ResolvedRepositoryReference}; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::Version; use uv_pep508::{split_scheme, MarkerEnvironment, MarkerTree, VerbatimUrl, VerbatimUrlError}; use uv_platform_tags::{TagCompatibility, TagPriority, Tags}; use uv_pypi_types::{ - redact_credentials, ConflictPackage, Conflicts, HashDigest, ParsedArchiveUrl, ParsedGitUrl, - Requirement, RequirementSource, + redact_credentials, ConflictPackage, Conflicts, HashDigest, ParsedArchiveUrl, + ParsedGitDirectoryUrl, ParsedGitPathUrl, Requirement, RequirementSource, }; use uv_types::{BuildContext, HashStrategy}; use uv_workspace::dependency_groups::DependencyGroupError; @@ -449,10 +450,16 @@ impl Lock { if let Some(requires_hash) = dist.id.source.requires_hash() { for wheel in &dist.wheels { if requires_hash != wheel.hash.is_some() { - return Err(LockErrorKind::Hash { - id: dist.id.clone(), - artifact_type: "wheel", - expected: requires_hash, + return Err(if requires_hash { + LockErrorKind::MissingHash { + id: dist.id.clone(), + artifact_type: "wheel", + } + } else { + LockErrorKind::UnexpectedHash { + id: dist.id.clone(), + artifact_type: "wheel", + } } .into()); } @@ -1714,11 +1721,47 @@ impl Package { let built_dist = BuiltDist::DirectUrl(direct_dist); Ok(Dist::Built(built_dist)) } - Source::Git(_, _) => Err(LockErrorKind::InvalidWheelSource { - id: self.id.clone(), - source_type: "Git", + 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(); + 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, + ); + + // Reconstruct the PEP 508-compatible URL from the `GitSource`. + let url = Url::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); + Ok(Dist::Built(built_dist)) } - .into()), Source::Directory(_) => Err(LockErrorKind::InvalidWheelSource { id: self.id.clone(), source_type: "directory", @@ -1839,25 +1882,45 @@ impl Package { url.set_query(None); // Reconstruct the `GitUrl` from the `GitSource`. - let git_url = uv_git::GitUrl::from_commit( - url, - GitReference::from(git.kind.clone()), - git.precise, - ); + let git_url = + GitUrl::from_commit(url, GitReference::from(git.kind.clone()), git.precise); - // Reconstruct the PEP 508-compatible URL from the `GitSource`. - let url = Url::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)? 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 = Url::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 = Url::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. @@ -2424,6 +2487,9 @@ impl Source { Ok(Source::from_direct_built_dist(direct_dist)) } BuiltDist::Path(ref path_dist) => Source::from_path_built_dist(path_dist, root), + BuiltDist::GitPath(ref git_dist) => { + Ok(Source::from_git_path_built_dist(git_dist, root)?) + } } } @@ -2438,8 +2504,11 @@ impl Source { uv_distribution_types::SourceDist::DirectUrl(ref direct_dist) => { Ok(Source::from_direct_source_dist(direct_dist)) } - uv_distribution_types::SourceDist::Git(ref git_dist) => { - Ok(Source::from_git_dist(git_dist)) + uv_distribution_types::SourceDist::GitDirectory(ref git_dist) => { + Ok(Source::from_git_directory_source_dist(git_dist)) + } + uv_distribution_types::SourceDist::GitPath(ref git_dist) => { + Ok(Source::from_git_path_source_dist(git_dist, root)?) } uv_distribution_types::SourceDist::Path(ref path_dist) => { Source::from_path_source_dist(path_dist, root) @@ -2529,15 +2598,68 @@ impl Source { } } - fn from_git_dist(git_dist: &GitSourceDist) -> Source { + 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(Source::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), + }, + )) + } + + 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(Source::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), + }, + )) + } + + fn from_git_directory_source_dist(git_dist: &GitDirectorySourceDist) -> Source { Source::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, }, ) } @@ -2664,12 +2786,11 @@ impl Source { /// /// Returns `None` to indicate that the source kind _may_ include a hash. fn requires_hash(&self) -> Option { - match *self { + 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), } } } @@ -2833,6 +2954,7 @@ struct DirectSource { struct GitSource { precise: GitSha, subdirectory: Option, + path: Option, kind: GitSourceKind, } @@ -2849,12 +2971,14 @@ impl GitSource { fn from_url(url: &Url) -> Result { let mut kind = GitSourceKind::DefaultBranch; let mut subdirectory = None; + let mut path = None; for (key, val) in url.query_pairs() { match &*key { "tag" => kind = GitSourceKind::Tag(val.into_owned()), "branch" => kind = GitSourceKind::Branch(val.into_owned()), "rev" => kind = GitSourceKind::Rev(val.into_owned()), "subdirectory" => subdirectory = Some(PortablePathBuf::from(val.as_ref()).into()), + "path" => path = Some(PortablePathBuf::from(val.as_ref()).into()), _ => continue, }; } @@ -2864,6 +2988,7 @@ impl GitSource { Ok(GitSource { precise, subdirectory, + path, kind, }) } @@ -3005,10 +3130,10 @@ impl SourceDist { uv_distribution_types::SourceDist::Path(_) => { SourceDist::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(_) => { + SourceDist::from_git_path_dist(id, hashes).map(Some) + } + uv_distribution_types::SourceDist::GitDirectory(_) | uv_distribution_types::SourceDist::Directory(_) => Ok(None), } } @@ -3059,10 +3184,9 @@ impl SourceDist { fn from_direct_dist(id: &PackageId, hashes: &[HashDigest]) -> Result { let Some(hash) = hashes.iter().max().cloned().map(Hash::from) else { - let kind = LockErrorKind::Hash { + let kind = LockErrorKind::MissingHash { id: id.clone(), artifact_type: "direct URL source distribution", - expected: true, }; return Err(kind.into()); }; @@ -3076,10 +3200,25 @@ impl SourceDist { fn from_path_dist(id: &PackageId, hashes: &[HashDigest]) -> Result { let Some(hash) = hashes.iter().max().cloned().map(Hash::from) else { - let kind = LockErrorKind::Hash { + let kind = LockErrorKind::MissingHash { id: id.clone(), artifact_type: "path source distribution", - expected: true, + }; + return Err(kind.into()); + }; + Ok(SourceDist::Metadata { + metadata: SourceDistMetadata { + hash: Some(hash), + size: None, + }, + }) + } + + fn from_git_path_dist(id: &PackageId, hashes: &[HashDigest]) -> Result { + let Some(hash) = hashes.iter().max().cloned().map(Hash::from) else { + let kind = LockErrorKind::MissingHash { + id: id.clone(), + artifact_type: "Git archive source distribution", }; return Err(kind.into()); }; @@ -3175,9 +3314,9 @@ impl From for GitReference { } } -/// Construct the lockfile-compatible [`URL`] for a [`GitSourceDist`]. -fn locked_git_url(git_dist: &GitSourceDist) -> Url { - let mut url = git_dist.git.repository().clone(); +/// Construct the lockfile-compatible [`URL`] for a [`GitPathSourceDist`]. +fn locked_git_url(git: &GitUrl, subdirectory: Option<&Path>, path: Option<&Path>) -> Url { + let mut url = git.repository().clone(); // Redact the credentials. redact_credentials(&mut url); @@ -3187,9 +3326,7 @@ fn locked_git_url(git_dist: &GitSourceDist) -> Url { 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) @@ -3198,8 +3335,17 @@ fn locked_git_url(git_dist: &GitSourceDist) -> Url { .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 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.to_string().as_str()); @@ -3220,14 +3366,7 @@ fn locked_git_url(git_dist: &GitSourceDist) -> Url { } // Put the precise commit in the fragment. - url.set_fragment( - git_dist - .git - .precise() - .as_ref() - .map(GitSha::to_string) - .as_deref(), - ); + url.set_fragment(git.precise().as_ref().map(GitSha::to_string).as_deref()); url } @@ -3305,6 +3444,9 @@ impl Wheel { Ok(vec![Wheel::from_direct_dist(direct_dist, hashes)]) } BuiltDist::Path(ref path_dist) => Ok(vec![Wheel::from_path_dist(path_dist, hashes)]), + BuiltDist::GitPath(ref git_dist) => { + Ok(vec![Wheel::from_git_path_dist(git_dist, hashes)]) + } } } @@ -3384,6 +3526,17 @@ impl Wheel { } } + fn from_git_path_dist(path_dist: &GitPathBuiltDist, hashes: &[HashDigest]) -> Wheel { + Wheel { + url: WheelWireSource::Filename { + filename: path_dist.filename.clone(), + }, + hash: hashes.iter().max().cloned().map(Hash::from), + size: None, + filename: path_dist.filename.clone(), + } + } + fn to_registry_dist( &self, source: &RegistrySource, @@ -3768,7 +3921,7 @@ fn normalize_requirement( workspace: &Workspace, ) -> Result { match requirement.source { - RequirementSource::Git { + RequirementSource::GitDirectory { mut repository, reference, precise, @@ -3788,7 +3941,7 @@ fn normalize_requirement( extras: requirement.extras, groups: requirement.groups, marker: requirement.marker, - source: RequirementSource::Git { + source: RequirementSource::GitDirectory { repository, reference, precise, @@ -3798,6 +3951,38 @@ fn normalize_requirement( origin: None, }) } + RequirementSource::GitPath { + mut repository, + reference, + precise, + install_path, + ext, + url, + } => { + // Redact the credentials. + redact_credentials(&mut repository); + + // Redact the PEP 508 URL. + let mut url = url.to_url(); + redact_credentials(&mut url); + let url = VerbatimUrl::from_url(url); + + Ok(Requirement { + name: requirement.name, + extras: requirement.extras, + groups: requirement.groups, + marker: requirement.marker, + source: RequirementSource::GitPath { + repository, + reference, + precise, + install_path, + ext, + url, + }, + origin: None, + }) + } RequirementSource::Path { install_path, ext, @@ -4001,17 +4186,25 @@ enum LockErrorKind { /// entry. dependency: Dependency, }, - /// An error that occurs when a hash is expected (or not) for a particular - /// artifact, but one was not found (or was). - #[error("Since the package `{id}` comes from a {source} dependency, a hash was {expected} but one was not found for {artifact_type}", source = id.source.name(), expected = if *expected { "expected" } else { "not expected" })] - Hash { + /// An error that occurs when a hash is expected for a particular + /// artifact, but one was not found. + #[error("Since the package `{id}` comes from a {source} dependency, a hash was expected but one was not found for {artifact_type}", source = id.source.name())] + MissingHash { + /// The ID of the package that has a missing hash. + id: PackageId, + /// The specific type of artifact, e.g., "source package" + /// or "wheel". + artifact_type: &'static str, + }, + /// An error that occurs when a hash is not expected for a particular + /// artifact, but one was found. + #[error("Since the package `{id}` comes from a {source} dependency, a hash was not expected but one was found for {artifact_type}", source = id.source.name())] + UnexpectedHash { /// The ID of the package that has a missing hash. id: PackageId, /// The specific type of artifact, e.g., "source package" /// or "wheel". artifact_type: &'static str, - /// When true, a hash is expected to be present. - expected: bool, }, /// An error that occurs when a package is included with an extra name, /// but no corresponding base package (i.e., without the extra) exists. diff --git a/crates/uv-resolver/src/lock/requirements_txt.rs b/crates/uv-resolver/src/lock/requirements_txt.rs index b398a23bb051..d6deb8cad98a 100644 --- a/crates/uv-resolver/src/lock/requirements_txt.rs +++ b/crates/uv-resolver/src/lock/requirements_txt.rs @@ -16,7 +16,7 @@ use uv_fs::Simplified; use uv_git::GitReference; use uv_normalize::{ExtraName, PackageName}; use uv_pep508::MarkerTree; -use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl}; +use uv_pypi_types::{ParsedArchiveUrl, ParsedGitDirectoryUrl}; use crate::graph_ops::marker_reachability; use crate::lock::{Package, PackageId, Source}; @@ -248,7 +248,7 @@ impl std::fmt::Display for RequirementsTxtExport<'_> { ); // Reconstruct the PEP 508-compatible URL from the `GitSource`. - let url = Url::from(ParsedGitUrl { + let url = Url::from(ParsedGitDirectoryUrl { url: git_url.clone(), subdirectory: git.subdirectory.as_ref().map(PathBuf::from), }); diff --git a/crates/uv-resolver/src/pubgrub/dependencies.rs b/crates/uv-resolver/src/pubgrub/dependencies.rs index 58cf53172b95..c8401cd97de9 100644 --- a/crates/uv-resolver/src/pubgrub/dependencies.rs +++ b/crates/uv-resolver/src/pubgrub/dependencies.rs @@ -6,8 +6,8 @@ use pubgrub::Ranges; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pypi_types::{ - Conflicts, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl, - Requirement, RequirementSource, VerbatimParsedUrl, + Conflicts, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitDirectoryUrl, ParsedGitPathUrl, + ParsedPathUrl, ParsedUrl, Requirement, RequirementSource, VerbatimParsedUrl, }; use crate::pubgrub::{PubGrubPackage, PubGrubPackageInner}; @@ -177,14 +177,14 @@ impl PubGrubRequirement { )); (url, parsed_url) } - RequirementSource::Git { + RequirementSource::GitDirectory { repository, reference, precise, url, subdirectory, } => { - let parsed_url = ParsedUrl::Git(ParsedGitUrl::from_source( + let parsed_url = ParsedUrl::GitDirectory(ParsedGitDirectoryUrl::from_source( repository.clone(), reference.clone(), *precise, @@ -192,6 +192,23 @@ impl PubGrubRequirement { )); (url, parsed_url) } + RequirementSource::GitPath { + repository, + reference, + precise, + install_path, + ext, + url, + } => { + let parsed_url = ParsedUrl::GitPath(ParsedGitPathUrl::from_source( + repository.clone(), + reference.clone(), + *precise, + install_path.clone(), + *ext, + )); + (url, parsed_url) + } RequirementSource::Path { ext, url, diff --git a/crates/uv-resolver/src/redirect.rs b/crates/uv-resolver/src/redirect.rs index af9cb64dd1c7..54a1fb379acc 100644 --- a/crates/uv-resolver/src/redirect.rs +++ b/crates/uv-resolver/src/redirect.rs @@ -1,36 +1,62 @@ use url::Url; use uv_git::{GitReference, GitResolver}; use uv_pep508::VerbatimUrl; -use uv_pypi_types::{ParsedGitUrl, ParsedUrl, VerbatimParsedUrl}; +use uv_pypi_types::{ParsedGitDirectoryUrl, ParsedGitPathUrl, ParsedUrl, VerbatimParsedUrl}; /// 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 { - debug_assert!( - matches!(git_url.reference(), GitReference::FullCommit(_)), - "Unseen Git URL: {}, {git_url:?}", - url.verbatim, - ); - return url; - }; - - let new_parsed_url = ParsedGitUrl { - url: new_git_url, - subdirectory: subdirectory.clone(), - }; - let new_url = Url::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, + match &url.parsed_url { + ParsedUrl::GitDirectory(ParsedGitDirectoryUrl { + url: git_url, + subdirectory, + }) => { + let Some(new_git_url) = git.precise(git_url.clone()) else { + debug_assert!( + matches!(git_url.reference(), GitReference::FullCommit(_)), + "Unseen Git URL: {}, {git_url:?}", + url.verbatim, + ); + return url; + }; + + let new_parsed_url = ParsedGitDirectoryUrl { + url: new_git_url, + subdirectory: subdirectory.clone(), + }; + let new_url = Url::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 { + debug_assert!( + matches!(git_url.reference(), GitReference::FullCommit(_)), + "Unseen Git URL: {}, {git_url:?}", + url.verbatim, + ); + return url; + }; + + let new_parsed_url = ParsedGitPathUrl { + url: new_git_url, + install_path: install_path.clone(), + ext: *ext, + }; + let new_url = Url::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, + } + } + _ => url, } } diff --git a/crates/uv-resolver/src/resolution/mod.rs b/crates/uv-resolver/src/resolution/mod.rs index 43eed44c2978..3a154b263686 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/resolver/urls.rs b/crates/uv-resolver/src/resolver/urls.rs index 015bb39b2a29..7c65addb1581 100644 --- a/crates/uv-resolver/src/resolver/urls.rs +++ b/crates/uv-resolver/src/resolver/urls.rs @@ -192,11 +192,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 2ac08effa3b5..e778f2bf729a 100644 --- a/crates/uv-types/src/hash.rs +++ b/crates/uv-types/src/hash.rs @@ -314,7 +314,8 @@ impl HashStrategy { )) } RequirementSource::Url { url, .. } - | RequirementSource::Git { url, .. } + | RequirementSource::GitPath { url, .. } + | RequirementSource::GitDirectory { url, .. } | RequirementSource::Path { url, .. } | RequirementSource::Directory { url, .. } => Some(VersionId::from_url(url)), } diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index d415ec701c5e..e1f4e81b367a 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -891,8 +891,10 @@ pub enum Source { Git { /// The repository URL (without the `git+` prefix). git: Url, - /// 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, @@ -1037,11 +1039,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`", @@ -1052,6 +1049,11 @@ impl<'de> Deserialize<'de> for Source { "cannot specify both `git` and `editable`", )); } + 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()) { @@ -1076,6 +1078,7 @@ impl<'de> Deserialize<'de> for Source { return Ok(Self::Git { git, subdirectory, + path, rev, tag, branch, @@ -1334,7 +1337,7 @@ impl Source { root: &Path, ) -> Result, SourceError> { // If we resolved to a non-Git source, and the user specified a Git reference, error. - if !matches!(source, RequirementSource::Git { .. }) { + if !matches!(source, RequirementSource::GitDirectory { .. }) { if let Some(rev) = rev { return Err(SourceError::UnusedRev(name.to_string(), rev)); } @@ -1360,7 +1363,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 { .. } => { @@ -1406,7 +1412,7 @@ impl Source { extra: None, group: None, }, - RequirementSource::Git { + RequirementSource::GitDirectory { repository, mut reference, subdirectory, @@ -1429,6 +1435,7 @@ impl Source { branch, git: repository, subdirectory: subdirectory.map(PortablePathBuf::from), + path: None, marker: MarkerTree::TRUE, extra: None, group: None, @@ -1440,6 +1447,49 @@ impl Source { branch, git: repository, subdirectory: subdirectory.map(PortablePathBuf::from), + path: None, + marker: MarkerTree::TRUE, + extra: None, + group: None, + } + } + } + RequirementSource::GitPath { + repository, + mut reference, + install_path, + .. + } => { + if rev.is_none() && tag.is_none() && branch.is_none() { + let rev = match reference { + GitReference::FullCommit(ref mut rev) => Some(mem::take(rev)), + GitReference::Branch(ref mut rev) => Some(mem::take(rev)), + GitReference::Tag(ref mut rev) => Some(mem::take(rev)), + GitReference::ShortCommit(ref mut rev) => Some(mem::take(rev)), + GitReference::BranchOrTag(ref mut rev) => Some(mem::take(rev)), + GitReference::BranchOrTagOrCommit(ref mut rev) => Some(mem::take(rev)), + GitReference::NamedRef(ref mut rev) => Some(mem::take(rev)), + GitReference::DefaultBranch => None, + }; + Source::Git { + rev, + tag, + branch, + git: repository, + subdirectory: None, + path: Some(PortablePathBuf::from(install_path)), + marker: MarkerTree::TRUE, + extra: None, + group: None, + } + } else { + Source::Git { + rev, + tag, + branch, + git: repository, + subdirectory: None, + path: Some(PortablePathBuf::from(install_path)), marker: MarkerTree::TRUE, extra: None, group: None, diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index c0bb80796880..da04e39651ae 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -471,6 +471,7 @@ pub(crate) async fn add( Some(Source::Git { mut git, subdirectory, + path, rev, tag, branch, @@ -489,6 +490,7 @@ pub(crate) async fn add( Some(Source::Git { git, subdirectory, + path, rev, tag, branch, @@ -972,7 +974,7 @@ fn augment_requirement( UnresolvedRequirement::Named(requirement) => { UnresolvedRequirement::Named(uv_pypi_types::Requirement { source: match requirement.source { - RequirementSource::Git { + RequirementSource::GitDirectory { repository, reference, precise, @@ -988,7 +990,7 @@ fn augment_requirement( } else { reference }; - RequirementSource::Git { + RequirementSource::GitDirectory { repository, reference, precise, @@ -1004,7 +1006,7 @@ fn augment_requirement( UnresolvedRequirement::Unnamed(requirement) => { UnresolvedRequirement::Unnamed(UnnamedRequirement { url: match requirement.url.parsed_url { - ParsedUrl::Git(mut git) => { + ParsedUrl::GitDirectory(mut git) => { let reference = if let Some(rev) = rev { Some(GitReference::from_rev(rev.to_string())) } else if let Some(tag) = tag { @@ -1016,7 +1018,7 @@ fn augment_requirement( git.url = git.url.with_reference(reference); } VerbatimParsedUrl { - parsed_url: ParsedUrl::Git(git), + parsed_url: ParsedUrl::GitDirectory(git), verbatim: requirement.url.verbatim, } } diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index f1e385dc7b26..2d3f61c2402f 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -20,7 +20,7 @@ use uv_installer::SitePackages; use uv_normalize::PackageName; use uv_pep508::{MarkerTree, Requirement, VersionOrUrl}; use uv_pypi_types::{ - LenientRequirement, ParsedArchiveUrl, ParsedGitUrl, ParsedUrl, VerbatimParsedUrl, + LenientRequirement, ParsedArchiveUrl, ParsedGitDirectoryUrl, ParsedUrl, VerbatimParsedUrl, }; use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; use uv_resolver::{FlatIndex, InstallTarget}; @@ -604,7 +604,7 @@ fn store_credentials_from_workspace(workspace: &Workspace) { continue; }; match &url.parsed_url { - ParsedUrl::Git(ParsedGitUrl { url, .. }) => { + ParsedUrl::GitDirectory(ParsedGitDirectoryUrl { url, .. }) => { uv_git::store_credentials_from_url(url.repository()); } ParsedUrl::Archive(ParsedArchiveUrl { url, .. }) => { diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 2d8220444a4d..0780e78a3f25 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -941,6 +941,244 @@ 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 = "git")] +fn lock_sdist_git_archive() -> Result<()> { + let context = TestContext::new("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 + 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 ----- + Audited 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 = "git")] +fn lock_wheel_git_archive() -> Result<()> { + let context = TestContext::new("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 + 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 ----- + Audited 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 ead9c4063c92..1dbbb3a79efb 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -5527,6 +5527,96 @@ fn sync_git_repeated_member_backwards_path() -> Result<()> { Ok(()) } +/// A Git repository that points to a pre-built archive within the repository. +#[test] +fn sync_git_path_archive() -> Result<()> { + let context = TestContext::new("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 + 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] diff --git a/uv.schema.json b/uv.schema.json index c5eaf633fa6c..165351325289 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -1506,6 +1506,17 @@ "marker": { "$ref": "#/definitions/MarkerTree" }, + "path": { + "description": "The path to the archive within the repository.", + "anyOf": [ + { + "$ref": "#/definitions/String" + }, + { + "type": "null" + } + ] + }, "rev": { "type": [ "string", @@ -1513,7 +1524,7 @@ ] }, "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/String"