diff --git a/crates/uv-dispatch/src/lib.rs b/crates/uv-dispatch/src/lib.rs index 73214667c531..7186e44b1c87 100644 --- a/crates/uv-dispatch/src/lib.rs +++ b/crates/uv-dispatch/src/lib.rs @@ -29,8 +29,8 @@ use uv_installer::{Installer, Plan, Planner, Preparer, SitePackages}; use uv_pypi_types::{ConflictingGroupList, Requirement}; use uv_python::{Interpreter, PythonEnvironment}; use uv_resolver::{ - ExcludeNewer, FlatIndex, Flexibility, InMemoryIndex, Manifest, OptionsBuilder, - PythonRequirement, Resolver, ResolverEnvironment, + DerivationChainBuilder, ExcludeNewer, FlatIndex, Flexibility, InMemoryIndex, Manifest, + OptionsBuilder, PythonRequirement, Resolver, ResolverEnvironment, }; use uv_types::{BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy, InFlight}; @@ -278,7 +278,33 @@ impl<'a> BuildContext for BuildDispatch<'a> { remote.iter().map(ToString::to_string).join(", ") ); - preparer.prepare(remote, self.in_flight).await? + preparer + .prepare(remote, self.in_flight) + .await + .map_err(|err| match err { + uv_installer::PrepareError::DownloadAndBuild(dist, chain, err) => { + debug_assert!(chain.is_empty()); + let chain = + DerivationChainBuilder::from_resolution(resolution, (&*dist).into()) + .unwrap_or_default(); + uv_installer::PrepareError::DownloadAndBuild(dist, chain, err) + } + uv_installer::PrepareError::Download(dist, chain, err) => { + debug_assert!(chain.is_empty()); + let chain = + DerivationChainBuilder::from_resolution(resolution, (&*dist).into()) + .unwrap_or_default(); + uv_installer::PrepareError::Download(dist, chain, err) + } + uv_installer::PrepareError::Build(dist, chain, err) => { + debug_assert!(chain.is_empty()); + let chain = + DerivationChainBuilder::from_resolution(resolution, (&*dist).into()) + .unwrap_or_default(); + uv_installer::PrepareError::Build(dist, chain, err) + } + _ => err, + })? }; // Remove any unnecessary packages. diff --git a/crates/uv-distribution-types/src/derivation.rs b/crates/uv-distribution-types/src/derivation.rs new file mode 100644 index 000000000000..e114945fb0da --- /dev/null +++ b/crates/uv-distribution-types/src/derivation.rs @@ -0,0 +1,82 @@ +use uv_normalize::PackageName; +use uv_pep440::Version; + +/// A chain of derivation steps from the root package to the current package, to explain why a +/// package is included in the resolution. +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub struct DerivationChain(Vec); + +impl FromIterator for DerivationChain { + fn from_iter>(iter: T) -> Self { + Self(iter.into_iter().collect()) + } +} + +impl DerivationChain { + /// Returns the length of the derivation chain. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Returns `true` if the derivation chain is empty. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Returns an iterator over the steps in the derivation chain. + pub fn iter(&self) -> std::slice::Iter { + self.0.iter() + } +} + +impl std::fmt::Display for DerivationChain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (idx, step) in self.0.iter().enumerate() { + if idx > 0 { + write!(f, " -> ")?; + } + write!(f, "{}=={}", step.name, step.version)?; + } + Ok(()) + } +} + +impl<'chain> IntoIterator for &'chain DerivationChain { + type Item = &'chain DerivationStep; + type IntoIter = std::slice::Iter<'chain, DerivationStep>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +impl IntoIterator for DerivationChain { + type Item = DerivationStep; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +/// A step in a derivation chain. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct DerivationStep { + /// The name of the package. + name: PackageName, + /// The version of the package. + version: Version, +} + +impl DerivationStep { + /// Create a [`DerivationStep`] from a package name and version. + pub fn new(name: PackageName, version: Version) -> Self { + Self { name, version } + } +} + +impl std::fmt::Display for DerivationStep { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}=={}", self.name, self.version) + } +} diff --git a/crates/uv-distribution-types/src/file.rs b/crates/uv-distribution-types/src/file.rs index e0bc7a4a4409..fd5efe150473 100644 --- a/crates/uv-distribution-types/src/file.rs +++ b/crates/uv-distribution-types/src/file.rs @@ -21,7 +21,7 @@ pub enum FileConversionError { } /// Internal analog to [`uv_pypi_types::File`]. -#[derive(Debug, Clone, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)] #[rkyv(derive(Debug))] pub struct File { pub dist_info_metadata: bool, @@ -66,7 +66,7 @@ impl File { } /// While a registry file is generally a remote URL, it can also be a file if it comes from a directory flat indexes. -#[derive(Debug, Clone, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)] #[rkyv(derive(Debug))] pub enum FileLocation { /// URL relative to the base URL. diff --git a/crates/uv-distribution-types/src/lib.rs b/crates/uv-distribution-types/src/lib.rs index ce5427b7695e..974e77eedc29 100644 --- a/crates/uv-distribution-types/src/lib.rs +++ b/crates/uv-distribution-types/src/lib.rs @@ -52,6 +52,7 @@ pub use crate::any::*; pub use crate::buildable::*; pub use crate::cached::*; pub use crate::dependency_metadata::*; +pub use crate::derivation::*; pub use crate::diagnostic::*; pub use crate::error::*; pub use crate::file::*; @@ -74,6 +75,7 @@ mod any; mod buildable; mod cached; mod dependency_metadata; +mod derivation; mod diagnostic; mod error; mod file; @@ -166,14 +168,21 @@ impl std::fmt::Display for InstalledVersion<'_> { /// Either a built distribution, a wheel, or a source distribution that exists at some location. /// /// The location can be an index, URL or path (wheel), or index, URL, path or Git repository (source distribution). -#[derive(Debug, Clone, Hash)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] pub enum Dist { Built(BuiltDist), Source(SourceDist), } +/// A reference to a built or source distribution. +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub enum DistRef<'a> { + Built(&'a BuiltDist), + Source(&'a SourceDist), +} + /// A wheel, with its three possible origins (index, url, path) -#[derive(Debug, Clone, Hash)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] #[allow(clippy::large_enum_variant)] pub enum BuiltDist { Registry(RegistryBuiltDist), @@ -182,7 +191,7 @@ pub enum BuiltDist { } /// A source distribution, with its possible origins (index, url, path, git) -#[derive(Debug, Clone, Hash)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] #[allow(clippy::large_enum_variant)] pub enum SourceDist { Registry(RegistrySourceDist), @@ -193,7 +202,7 @@ pub enum SourceDist { } /// A built distribution (wheel) that exists in a registry, like `PyPI`. -#[derive(Debug, Clone, Hash)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct RegistryBuiltWheel { pub filename: WheelFilename, pub file: Box, @@ -201,7 +210,7 @@ pub struct RegistryBuiltWheel { } /// A built distribution (wheel) that exists in a registry, like `PyPI`. -#[derive(Debug, Clone, Hash)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct RegistryBuiltDist { /// All wheels associated with this distribution. It is guaranteed /// that there is at least one wheel. @@ -231,7 +240,7 @@ pub struct RegistryBuiltDist { } /// A built distribution (wheel) that exists at an arbitrary URL. -#[derive(Debug, Clone, Hash)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct DirectUrlBuiltDist { /// We require that wheel urls end in the full wheel filename, e.g. /// `https://example.org/packages/flask-3.0.0-py3-none-any.whl` @@ -243,7 +252,7 @@ pub struct DirectUrlBuiltDist { } /// A built distribution (wheel) that exists in a local directory. -#[derive(Debug, Clone, Hash)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct PathBuiltDist { pub filename: WheelFilename, /// The absolute path to the wheel which we use for installing. @@ -253,7 +262,7 @@ pub struct PathBuiltDist { } /// A source distribution that exists in a registry, like `PyPI`. -#[derive(Debug, Clone, Hash)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct RegistrySourceDist { pub name: PackageName, pub version: Version, @@ -272,7 +281,7 @@ pub struct RegistrySourceDist { } /// A source distribution that exists at an arbitrary URL. -#[derive(Debug, Clone, Hash)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct DirectUrlSourceDist { /// Unlike [`DirectUrlBuiltDist`], we can't require a full filename with a version here, people /// like using e.g. `foo @ https://github.com/org/repo/archive/master.zip` @@ -288,7 +297,7 @@ pub struct DirectUrlSourceDist { } /// A source distribution that exists in a Git repository. -#[derive(Debug, Clone, Hash)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct GitSourceDist { pub name: PackageName, /// The URL without the revision and subdirectory fragment. @@ -300,7 +309,7 @@ pub struct GitSourceDist { } /// A source distribution that exists in a local archive (e.g., a `.tar.gz` file). -#[derive(Debug, Clone, Hash)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct PathSourceDist { pub name: PackageName, /// The absolute path to the distribution which we use for installing. @@ -312,7 +321,7 @@ pub struct PathSourceDist { } /// A source distribution that exists in a local directory. -#[derive(Debug, Clone, Hash)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct DirectorySourceDist { pub name: PackageName, /// The absolute path to the distribution which we use for installing. @@ -512,12 +521,33 @@ impl Dist { } } + /// Returns the version of the distribution, if it is known. pub fn version(&self) -> Option<&Version> { match self { Self::Built(wheel) => Some(wheel.version()), Self::Source(source_dist) => source_dist.version(), } } + + /// Convert this distribution into a reference. + pub fn as_ref(&self) -> DistRef { + match self { + Self::Built(dist) => DistRef::Built(dist), + Self::Source(dist) => DistRef::Source(dist), + } + } +} + +impl<'a> From<&'a SourceDist> for DistRef<'a> { + fn from(dist: &'a SourceDist) -> Self { + DistRef::Source(dist) + } +} + +impl<'a> From<&'a BuiltDist> for DistRef<'a> { + fn from(dist: &'a BuiltDist) -> Self { + DistRef::Built(dist) + } } impl BuiltDist { diff --git a/crates/uv-distribution-types/src/resolution.rs b/crates/uv-distribution-types/src/resolution.rs index ed935a0909ef..48ef52621010 100644 --- a/crates/uv-distribution-types/src/resolution.rs +++ b/crates/uv-distribution-types/src/resolution.rs @@ -174,8 +174,6 @@ impl Diagnostic for ResolutionDiagnostic { /// A node in the resolution, along with whether its been filtered out. /// -/// This is similar to [`ResolutionGraph`], but represents a resolution for a single platform. -/// /// We retain filtered nodes as we still need to be able to trace dependencies through the graph /// (e.g., to determine why a package was install in the resolution). #[derive(Debug, Clone)] diff --git a/crates/uv-installer/src/preparer.rs b/crates/uv-installer/src/preparer.rs index 3d05ecda4996..ba40a60a04c7 100644 --- a/crates/uv-installer/src/preparer.rs +++ b/crates/uv-installer/src/preparer.rs @@ -9,29 +9,13 @@ use uv_cache::Cache; use uv_configuration::BuildOptions; use uv_distribution::{DistributionDatabase, LocalWheel}; use uv_distribution_types::{ - BuildableSource, BuiltDist, CachedDist, Dist, Hashed, Identifier, Name, RemoteSource, - SourceDist, + BuildableSource, BuiltDist, CachedDist, DerivationChain, Dist, Hashed, Identifier, Name, + RemoteSource, SourceDist, }; use uv_pep508::PackageName; use uv_platform_tags::Tags; use uv_types::{BuildContext, HashStrategy, InFlight}; -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("Building source distributions is disabled, but attempted to build `{0}`")] - NoBuild(PackageName), - #[error("Using pre-built wheels is disabled, but attempted to use `{0}`")] - NoBinary(PackageName), - #[error("Failed to download `{0}`")] - Download(Box, #[source] uv_distribution::Error), - #[error("Failed to download and build `{0}`")] - DownloadAndBuild(Box, #[source] uv_distribution::Error), - #[error("Failed to build `{0}`")] - Build(Box, #[source] uv_distribution::Error), - #[error("Unzip failed in another thread: {0}")] - Thread(String), -} - /// Prepare distributions for installation. /// /// Downloads, builds, and unzips a set of distributions. @@ -145,16 +129,7 @@ impl<'a, Context: BuildContext> Preparer<'a, Context> { .database .get_or_build_wheel(&dist, self.tags, policy) .boxed_local() - .map_err(|err| match dist.clone() { - Dist::Built(dist) => Error::Download(Box::new(dist), err), - Dist::Source(dist) => { - if dist.is_local() { - Error::Build(Box::new(dist), err) - } else { - Error::DownloadAndBuild(Box::new(dist), err) - } - } - }) + .map_err(|err| Error::from_dist(dist.clone(), err)) .await .and_then(|wheel: LocalWheel| { if wheel.satisfies(policy) { @@ -165,16 +140,7 @@ impl<'a, Context: BuildContext> Preparer<'a, Context> { policy.digests(), wheel.hashes(), ); - Err(match dist { - Dist::Built(dist) => Error::Download(Box::new(dist), err), - Dist::Source(dist) => { - if dist.is_local() { - Error::Build(Box::new(dist), err) - } else { - Error::DownloadAndBuild(Box::new(dist), err) - } - } - }) + Err(Error::from_dist(dist, err)) } }) .map(CachedDist::from); @@ -203,6 +169,50 @@ impl<'a, Context: BuildContext> Preparer<'a, Context> { } } +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Building source distributions is disabled, but attempted to build `{0}`")] + NoBuild(PackageName), + #[error("Using pre-built wheels is disabled, but attempted to use `{0}`")] + NoBinary(PackageName), + #[error("Failed to download `{0}`")] + Download( + Box, + DerivationChain, + #[source] uv_distribution::Error, + ), + #[error("Failed to download and build `{0}`")] + DownloadAndBuild( + Box, + DerivationChain, + #[source] uv_distribution::Error, + ), + #[error("Failed to build `{0}`")] + Build( + Box, + DerivationChain, + #[source] uv_distribution::Error, + ), + #[error("Unzip failed in another thread: {0}")] + Thread(String), +} + +impl Error { + /// Create an [`Error`] from a distribution error. + fn from_dist(dist: Dist, cause: uv_distribution::Error) -> Self { + match dist { + Dist::Built(dist) => Self::Download(Box::new(dist), DerivationChain::default(), cause), + Dist::Source(dist) => { + if dist.is_local() { + Self::Build(Box::new(dist), DerivationChain::default(), cause) + } else { + Self::DownloadAndBuild(Box::new(dist), DerivationChain::default(), cause) + } + } + } + } +} + pub trait Reporter: Send + Sync { /// Callback to invoke when a wheel is unzipped. This implies that the wheel was downloaded and, /// if necessary, built. diff --git a/crates/uv-requirements/src/extras.rs b/crates/uv-requirements/src/extras.rs index bca7fe87da68..2d72f6b1f214 100644 --- a/crates/uv-requirements/src/extras.rs +++ b/crates/uv-requirements/src/extras.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use futures::{stream::FuturesOrdered, TryStreamExt}; use uv_distribution::{DistributionDatabase, Reporter}; -use uv_distribution_types::{Dist, DistributionMetadata}; +use uv_distribution_types::DistributionMetadata; use uv_pypi_types::Requirement; use uv_resolver::{InMemoryIndex, MetadataResponse}; use uv_types::{BuildContext, HashStrategy}; @@ -100,16 +100,7 @@ impl<'a, Context: BuildContext> ExtrasResolver<'a, Context> { let archive = database .get_or_build_wheel_metadata(&dist, hasher.get(&dist)) .await - .map_err(|err| match dist { - Dist::Built(built) => Error::Download(Box::new(built), err), - Dist::Source(source) => { - if source.is_local() { - Error::Build(Box::new(source), err) - } else { - Error::DownloadAndBuild(Box::new(source), err) - } - } - })?; + .map_err(|err| Error::from_dist(dist, err))?; let metadata = archive.metadata.clone(); diff --git a/crates/uv-requirements/src/lib.rs b/crates/uv-requirements/src/lib.rs index 04bac27ec6d5..34db244db26e 100644 --- a/crates/uv-requirements/src/lib.rs +++ b/crates/uv-requirements/src/lib.rs @@ -1,13 +1,12 @@ -use uv_distribution_types::{BuiltDist, Dist, GitSourceDist, SourceDist}; -use uv_git::GitUrl; -use uv_pypi_types::{Requirement, RequirementSource}; - pub use crate::extras::*; pub use crate::lookahead::*; pub use crate::source_tree::*; pub use crate::sources::*; pub use crate::specification::*; pub use crate::unnamed::*; +use uv_distribution_types::{BuiltDist, DerivationChain, Dist, GitSourceDist, SourceDist}; +use uv_git::GitUrl; +use uv_pypi_types::{Requirement, RequirementSource}; mod extras; mod lookahead; @@ -20,13 +19,25 @@ pub mod upgrade; #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Failed to download `{0}`")] - Download(Box, #[source] uv_distribution::Error), + Download( + Box, + DerivationChain, + #[source] uv_distribution::Error, + ), #[error("Failed to download and build `{0}`")] - DownloadAndBuild(Box, #[source] uv_distribution::Error), + DownloadAndBuild( + Box, + DerivationChain, + #[source] uv_distribution::Error, + ), #[error("Failed to build `{0}`")] - Build(Box, #[source] uv_distribution::Error), + Build( + Box, + DerivationChain, + #[source] uv_distribution::Error, + ), #[error(transparent)] Distribution(#[from] uv_distribution::Error), @@ -38,6 +49,22 @@ pub enum Error { WheelFilename(#[from] uv_distribution_filename::WheelFilenameError), } +impl Error { + /// Create an [`Error`] from a distribution error. + pub(crate) fn from_dist(dist: Dist, cause: uv_distribution::Error) -> Self { + match dist { + Dist::Built(dist) => Self::Download(Box::new(dist), DerivationChain::default(), cause), + Dist::Source(dist) => { + if dist.is_local() { + Self::Build(Box::new(dist), DerivationChain::default(), cause) + } else { + Self::DownloadAndBuild(Box::new(dist), DerivationChain::default(), cause) + } + } + } + } +} + /// Convert a [`Requirement`] into a [`Dist`], if it is a direct URL. pub(crate) fn required_dist( requirement: &Requirement, diff --git a/crates/uv-requirements/src/lookahead.rs b/crates/uv-requirements/src/lookahead.rs index 0192c6313845..7a132f3642fd 100644 --- a/crates/uv-requirements/src/lookahead.rs +++ b/crates/uv-requirements/src/lookahead.rs @@ -174,16 +174,7 @@ impl<'a, Context: BuildContext> LookaheadResolver<'a, Context> { .database .get_or_build_wheel_metadata(&dist, self.hasher.get(&dist)) .await - .map_err(|err| match dist { - Dist::Built(built) => Error::Download(Box::new(built), err), - Dist::Source(source) => { - if source.is_local() { - Error::Build(Box::new(source), err) - } else { - Error::DownloadAndBuild(Box::new(source), err) - } - } - })?; + .map_err(|err| Error::from_dist(dist, err))?; let metadata = archive.metadata.clone(); diff --git a/crates/uv-resolver/src/error.rs b/crates/uv-resolver/src/error.rs index 87a9ab0f48dd..cf668c08946e 100644 --- a/crates/uv-resolver/src/error.rs +++ b/crates/uv-resolver/src/error.rs @@ -10,7 +10,8 @@ use rustc_hash::FxHashMap; use tracing::trace; use uv_distribution_types::{ - BuiltDist, IndexCapabilities, IndexLocations, IndexUrl, InstalledDist, SourceDist, + BuiltDist, DerivationChain, IndexCapabilities, IndexLocations, IndexUrl, InstalledDist, + SourceDist, }; use uv_normalize::{ExtraName, PackageName}; use uv_pep440::{LocalVersionSlice, Version}; @@ -97,20 +98,36 @@ pub enum ResolveError { ParsedUrl(#[from] uv_pypi_types::ParsedUrlError), #[error("Failed to download `{0}`")] - Download(Box, #[source] Arc), + Download( + Box, + DerivationChain, + #[source] Arc, + ), #[error("Failed to download and build `{0}`")] - DownloadAndBuild(Box, #[source] Arc), + DownloadAndBuild( + Box, + DerivationChain, + #[source] Arc, + ), #[error("Failed to read `{0}`")] - Read(Box, #[source] Arc), + Read( + Box, + DerivationChain, + #[source] Arc, + ), // TODO(zanieb): Use `thiserror` in `InstalledDist` so we can avoid chaining `anyhow` #[error("Failed to read metadata from installed package `{0}`")] - ReadInstalled(Box, #[source] anyhow::Error), + ReadInstalled(Box, DerivationChain, #[source] anyhow::Error), #[error("Failed to build `{0}`")] - Build(Box, #[source] Arc), + Build( + Box, + DerivationChain, + #[source] Arc, + ), #[error(transparent)] NoSolution(#[from] NoSolutionError), diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index 264641bd11df..131b51364196 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -18,9 +18,9 @@ pub use resolution::{ }; pub use resolution_mode::ResolutionMode; pub use resolver::{ - BuildId, DefaultResolverProvider, InMemoryIndex, MetadataResponse, PackageVersionsResult, - Reporter as ResolverReporter, Resolver, ResolverEnvironment, ResolverProvider, - VersionsResponse, WheelMetadataResult, + BuildId, DefaultResolverProvider, DerivationChainBuilder, InMemoryIndex, MetadataResponse, + PackageVersionsResult, Reporter as ResolverReporter, Resolver, ResolverEnvironment, + ResolverProvider, VersionsResponse, WheelMetadataResult, }; pub use version_map::VersionMap; pub use yanks::AllowedYanks; diff --git a/crates/uv-resolver/src/lock/target.rs b/crates/uv-resolver/src/lock/target.rs index 529939a18dd9..b95ad674bfa9 100644 --- a/crates/uv-resolver/src/lock/target.rs +++ b/crates/uv-resolver/src/lock/target.rs @@ -160,11 +160,17 @@ impl<'env> InstallTarget<'env> { /// Convert a lockfile entry to an installable distribution. macro_rules! node { ($dist:expr) => { - if install_options.include_package( - $dist.name(), - self.project_name(), - self.lock().members(), - ) { + node!( + $dist, + install_options.include_package( + $dist.name(), + self.project_name(), + self.lock().members(), + ) + ) + }; + ($dist:expr, $install:expr) => { + if $install { let dist = $dist.to_dist( self.workspace().install_path(), TagPolicy::Required(tags), @@ -219,12 +225,19 @@ impl<'env> InstallTarget<'env> { if dev.prod() { // Add the workspace package to the graph. - if let Entry::Vacant(entry) = inverse.entry(&dist.id) { - entry.insert(petgraph.add_node(node!(dist))); - } + let index = match inverse.entry(&dist.id) { + Entry::Vacant(entry) => { + let index = petgraph.add_node(node!(dist)); + entry.insert(index); + index + } + Entry::Occupied(entry) => { + let index = *entry.get(); + index + } + }; // Add an edge from the root. - let index = inverse[&dist.id]; petgraph.add_edge(root, index, Edge::Prod(MarkerTree::TRUE)); // Push its dependencies on the queue. @@ -250,19 +263,36 @@ impl<'env> InstallTarget<'env> { if dep.complexified_marker.evaluate(marker_env, &[]) { let dep_dist = self.lock().find_by_id(&dep.package_id); + // Add the workspace package to the graph. + let index = match inverse.entry(&dist.id) { + Entry::Vacant(entry) => { + let index = petgraph.add_node(node!(dist, false)); + entry.insert(index); + index + } + Entry::Occupied(entry) => { + let index = *entry.get(); + index + } + }; + // Add the dependency to the graph. - if let Entry::Vacant(entry) = inverse.entry(&dep.package_id) { - entry.insert(petgraph.add_node(node!(dep_dist))); - } + let dep_index = match inverse.entry(&dep.package_id) { + Entry::Vacant(entry) => { + let index = petgraph.add_node(node!(dep_dist)); + entry.insert(index); + index + } + Entry::Occupied(entry) => { + let index = *entry.get(); + index + } + }; - // Add an edge from the root. Development dependencies may be installed without - // installing the workspace package itself (which can never have markers on it - // anyway), so they're directly connected to the root. - let dep_index = inverse[&dep.package_id]; petgraph.add_edge( - root, + index, dep_index, - Edge::Dev(group.clone(), MarkerTree::TRUE), + Edge::Dev(group.clone(), dep.complexified_marker.clone()), ); // Push its dependencies on the queue. @@ -299,12 +329,19 @@ impl<'env> InstallTarget<'env> { })?; // Add the workspace package to the graph. - if let Entry::Vacant(entry) = inverse.entry(&dist.id) { - entry.insert(petgraph.add_node(node!(dist))); - } + let index = match inverse.entry(&dist.id) { + Entry::Vacant(entry) => { + let index = petgraph.add_node(node!(dist)); + entry.insert(index); + index + } + Entry::Occupied(entry) => { + let index = *entry.get(); + index + } + }; // Add an edge from the root. - let index = inverse[&dist.id]; petgraph.add_edge( root, index, @@ -339,12 +376,19 @@ impl<'env> InstallTarget<'env> { let dep_dist = self.lock().find_by_id(&dep.package_id); // Add the dependency to the graph. - if let Entry::Vacant(entry) = inverse.entry(&dep.package_id) { - entry.insert(petgraph.add_node(node!(dep_dist))); - } + let dep_index = match inverse.entry(&dep.package_id) { + Entry::Vacant(entry) => { + let index = petgraph.add_node(node!(dep_dist)); + entry.insert(index); + index + } + Entry::Occupied(entry) => { + let index = *entry.get(); + index + } + }; // Add the edge. - let dep_index = inverse[&dep.package_id]; petgraph.add_edge( index, dep_index, diff --git a/crates/uv-resolver/src/resolver/derivation.rs b/crates/uv-resolver/src/resolver/derivation.rs new file mode 100644 index 000000000000..0db7f24c6f59 --- /dev/null +++ b/crates/uv-resolver/src/resolver/derivation.rs @@ -0,0 +1,137 @@ +use std::collections::VecDeque; + +use petgraph::Direction; +use pubgrub::{Kind, SelectedDependencies, State}; +use rustc_hash::FxHashSet; + +use uv_distribution_types::{ + DerivationChain, DerivationStep, DistRef, Name, Node, Resolution, ResolvedDist, +}; +use uv_pep440::Version; + +use crate::dependency_provider::UvDependencyProvider; +use crate::pubgrub::PubGrubPackage; + +/// A chain of derivation steps from the root package to the current package, to explain why a +/// package is included in the resolution. +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub struct DerivationChainBuilder; + +impl DerivationChainBuilder { + /// Compute a [`DerivationChain`] from a resolution graph. + /// + /// This is used to construct a derivation chain upon install failure in the `uv pip` context, + /// where we don't have a lockfile describing the resolution. + pub fn from_resolution( + resolution: &Resolution, + target: DistRef<'_>, + ) -> Option { + // Find the target distribution in the resolution graph. + let target = resolution.graph().node_indices().find(|node| { + let Node::Dist { + dist: ResolvedDist::Installable { dist, .. }, + .. + } = &resolution.graph()[*node] + else { + return false; + }; + target == dist.as_ref() + })?; + + // Perform a BFS to find the shortest path to the root. + let mut queue = VecDeque::new(); + queue.push_back((target, Vec::new())); + + // TODO(charlie): Consider respecting markers here. + let mut seen = FxHashSet::default(); + while let Some((node, mut path)) = queue.pop_front() { + if !seen.insert(node) { + continue; + } + match &resolution.graph()[node] { + Node::Root => { + path.reverse(); + path.pop(); + return Some(DerivationChain::from_iter(path)); + } + Node::Dist { dist, .. } => { + path.push(DerivationStep::new( + dist.name().clone(), + dist.version().clone(), + )); + for neighbor in resolution + .graph() + .neighbors_directed(node, Direction::Incoming) + { + queue.push_back((neighbor, path.clone())); + } + } + } + } + + None + } + + /// Compute a [`DerivationChain`] from the current PubGrub state. + /// + /// This is used to construct a derivation chain upon resolution failure. + pub(crate) fn from_state( + package: &PubGrubPackage, + version: &Version, + state: &State, + ) -> Option { + /// Find a path from the current package to the root package. + fn find_path( + package: &PubGrubPackage, + version: &Version, + state: &State, + solution: &SelectedDependencies, + path: &mut Vec, + ) -> bool { + // Retrieve the incompatiblies for the current package. + let Some(incompats) = state.incompatibilities.get(package) else { + return false; + }; + for index in incompats { + let incompat = &state.incompatibility_store[*index]; + + // Find a dependency from a package to the current package. + if let Kind::FromDependencyOf(p1, _v1, p2, v2) = &incompat.kind { + if p2 == package && v2.contains(version) { + if let Some(version) = solution.get(p1) { + if let Some(name) = p1.name() { + // Add to the current path. + path.push(DerivationStep::new(name.clone(), version.clone())); + + // Recursively search the next package. + if find_path(p1, version, state, solution, path) { + return true; + } + + // Backtrack if the path didn't lead to the root. + path.pop(); + } else { + // If we've reached the root, return. + return true; + } + } + } + } + } + false + } + + let solution = state.partial_solution.extract_solution(); + let path = { + let mut path = vec![]; + if !find_path(package, version, state, &solution, &mut path) { + return None; + } + path.reverse(); + path.dedup(); + path + }; + + Some(path.into_iter().collect()) + } +} diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index 926ff1fb8361..86f316be714f 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -27,9 +27,9 @@ pub(crate) use urls::Urls; use uv_configuration::{Constraints, Overrides}; use uv_distribution::{ArchiveMetadata, DistributionDatabase}; use uv_distribution_types::{ - BuiltDist, CompatibleDist, Dist, DistributionMetadata, IncompatibleDist, IncompatibleSource, - IncompatibleWheel, IndexCapabilities, IndexLocations, IndexUrl, InstalledDist, - PythonRequirementKind, RemoteSource, ResolvedDist, ResolvedDistRef, SourceDist, + BuiltDist, CompatibleDist, DerivationChain, Dist, DistributionMetadata, IncompatibleDist, + IncompatibleSource, IncompatibleWheel, IndexCapabilities, IndexLocations, IndexUrl, + InstalledDist, PythonRequirementKind, RemoteSource, ResolvedDist, ResolvedDistRef, SourceDist, VersionOrUrlRef, }; use uv_git::GitResolver; @@ -63,6 +63,8 @@ pub(crate) use crate::resolver::availability::{ IncompletePackage, ResolverVersion, UnavailablePackage, UnavailableReason, UnavailableVersion, }; use crate::resolver::batch_prefetch::BatchPrefetcher; +pub use crate::resolver::derivation::DerivationChainBuilder; + use crate::resolver::groups::Groups; pub use crate::resolver::index::InMemoryIndex; use crate::resolver::indexes::Indexes; @@ -77,6 +79,7 @@ use crate::{marker, DependencyMode, Exclusions, FlatIndex, Options, ResolutionMo mod availability; mod batch_prefetch; +mod derivation; mod environment; mod fork_map; mod groups; @@ -388,6 +391,7 @@ impl ResolverState ResolverState { + // Then here, if we get a reason that we consider unrecoverable, we should + // show the derivation chain. state .pubgrub .add_incompatibility(Incompatibility::custom_version( @@ -946,25 +953,33 @@ impl ResolverState { + // TODO(charlie): Add derivation chain for URL dependencies. In practice, this isn't + // critical since we fetch URL dependencies _prior_ to invoking the resolver. + let chain = DerivationChain::default(); return Err(match &**dist { Dist::Built(built_dist @ BuiltDist::Path(_)) => { - ResolveError::Read(Box::new(built_dist.clone()), (*err).clone()) + ResolveError::Read(Box::new(built_dist.clone()), chain, (*err).clone()) } Dist::Source(source_dist @ SourceDist::Path(_)) => { - ResolveError::Build(Box::new(source_dist.clone()), (*err).clone()) + ResolveError::Build(Box::new(source_dist.clone()), chain, (*err).clone()) } Dist::Source(source_dist @ SourceDist::Directory(_)) => { - ResolveError::Build(Box::new(source_dist.clone()), (*err).clone()) + ResolveError::Build(Box::new(source_dist.clone()), chain, (*err).clone()) } Dist::Built(built_dist) => { - ResolveError::Download(Box::new(built_dist.clone()), (*err).clone()) + ResolveError::Download(Box::new(built_dist.clone()), chain, (*err).clone()) } Dist::Source(source_dist) => { if source_dist.is_local() { - ResolveError::Build(Box::new(source_dist.clone()), (*err).clone()) + ResolveError::Build( + Box::new(source_dist.clone()), + chain, + (*err).clone(), + ) } else { ResolveError::DownloadAndBuild( Box::new(source_dist.clone()), + chain, (*err).clone(), ) } @@ -1198,8 +1213,16 @@ impl ResolverState, ) -> Result { - let result = self.get_dependencies(package, version, fork_urls, env, python_requirement); + let result = self.get_dependencies( + package, + version, + fork_urls, + env, + python_requirement, + pubgrub, + ); if env.marker_environment().is_some() { result.map(|deps| match deps { Dependencies::Available(deps) | Dependencies::Unforkable(deps) => { @@ -1221,6 +1244,7 @@ impl ResolverState, ) -> Result { let url = package.name().and_then(|name| fork_urls.get(name)); let dependencies = match &**package { @@ -1358,28 +1382,42 @@ impl ResolverState { + let chain = DerivationChainBuilder::from_state(package, version, pubgrub) + .unwrap_or_default(); return Err(match &**dist { - Dist::Built(built_dist @ BuiltDist::Path(_)) => { - ResolveError::Read(Box::new(built_dist.clone()), (*err).clone()) - } - Dist::Source(source_dist @ SourceDist::Path(_)) => { - ResolveError::Build(Box::new(source_dist.clone()), (*err).clone()) - } + Dist::Built(built_dist @ BuiltDist::Path(_)) => ResolveError::Read( + Box::new(built_dist.clone()), + chain, + (*err).clone(), + ), + Dist::Source(source_dist @ SourceDist::Path(_)) => ResolveError::Build( + Box::new(source_dist.clone()), + chain, + (*err).clone(), + ), Dist::Source(source_dist @ SourceDist::Directory(_)) => { - ResolveError::Build(Box::new(source_dist.clone()), (*err).clone()) - } - Dist::Built(built_dist) => { - ResolveError::Download(Box::new(built_dist.clone()), (*err).clone()) + ResolveError::Build( + Box::new(source_dist.clone()), + chain, + (*err).clone(), + ) } + Dist::Built(built_dist) => ResolveError::Download( + Box::new(built_dist.clone()), + chain, + (*err).clone(), + ), Dist::Source(source_dist) => { if source_dist.is_local() { ResolveError::Build( Box::new(source_dist.clone()), + chain, (*err).clone(), ) } else { ResolveError::DownloadAndBuild( Box::new(source_dist.clone()), + chain, (*err).clone(), ) } @@ -1852,9 +1890,14 @@ impl ResolverState { - let metadata = dist - .metadata() - .map_err(|err| ResolveError::ReadInstalled(Box::new(dist.clone()), err))?; + // TODO(charlie): This should be return a `MetadataResponse`. + let metadata = dist.metadata().map_err(|err| { + ResolveError::ReadInstalled( + Box::new(dist.clone()), + DerivationChain::default(), + err, + ) + })?; Ok(Some(Response::Installed { dist, metadata })) } @@ -1968,7 +2011,11 @@ impl ResolverState { let metadata = dist.metadata().map_err(|err| { - ResolveError::ReadInstalled(Box::new(dist.clone()), err) + ResolveError::ReadInstalled( + Box::new(dist.clone()), + DerivationChain::default(), + err, + ) })?; Response::Installed { dist, metadata } } diff --git a/crates/uv/src/commands/diagnostics.rs b/crates/uv/src/commands/diagnostics.rs index 65ead831227c..8d0a3a2a456e 100644 --- a/crates/uv/src/commands/diagnostics.rs +++ b/crates/uv/src/commands/diagnostics.rs @@ -4,7 +4,7 @@ use std::sync::LazyLock; use owo_colors::OwoColorize; use rustc_hash::FxHashMap; -use uv_distribution_types::{BuiltDist, Name, SourceDist}; +use uv_distribution_types::{BuiltDist, DerivationChain, Name, SourceDist}; use uv_normalize::PackageName; use crate::commands::pip; @@ -42,7 +42,7 @@ impl OperationDiagnostic { pub(crate) fn with_hint(hint: String) -> Self { Self { hint: Some(hint), - context: None, + ..Default::default() } } @@ -50,8 +50,8 @@ impl OperationDiagnostic { #[must_use] pub(crate) fn with_context(context: &'static str) -> Self { Self { - hint: None, context: Some(context), + ..Default::default() } } @@ -74,47 +74,70 @@ impl OperationDiagnostic { } pip::operations::Error::Resolve(uv_resolver::ResolveError::DownloadAndBuild( dist, + chain, err, )) => { - download_and_build(dist, Box::new(err)); + download_and_build(dist, &chain, Box::new(err)); None } - pip::operations::Error::Resolve(uv_resolver::ResolveError::Download(dist, err)) => { - download(dist, Box::new(err)); + pip::operations::Error::Resolve(uv_resolver::ResolveError::Download( + dist, + chain, + err, + )) => { + download(dist, &chain, Box::new(err)); None } - pip::operations::Error::Resolve(uv_resolver::ResolveError::Build(dist, err)) => { - build(dist, Box::new(err)); + pip::operations::Error::Resolve(uv_resolver::ResolveError::Build(dist, chain, err)) => { + build(dist, &chain, Box::new(err)); None } pip::operations::Error::Requirements(uv_requirements::Error::DownloadAndBuild( dist, + chain, err, )) => { - download_and_build(dist, Box::new(err)); + download_and_build(dist, &chain, Box::new(err)); None } - pip::operations::Error::Requirements(uv_requirements::Error::Download(dist, err)) => { - download(dist, Box::new(err)); + pip::operations::Error::Requirements(uv_requirements::Error::Download( + dist, + chain, + err, + )) => { + download(dist, &chain, Box::new(err)); None } - pip::operations::Error::Requirements(uv_requirements::Error::Build(dist, err)) => { - build(dist, Box::new(err)); + pip::operations::Error::Requirements(uv_requirements::Error::Build( + dist, + chain, + err, + )) => { + build(dist, &chain, Box::new(err)); None } pip::operations::Error::Prepare(uv_installer::PrepareError::DownloadAndBuild( dist, + chain, err, )) => { - download_and_build(dist, Box::new(err)); + download_and_build(dist, &chain, Box::new(err)); None } - pip::operations::Error::Prepare(uv_installer::PrepareError::Download(dist, err)) => { - download(dist, Box::new(err)); + pip::operations::Error::Prepare(uv_installer::PrepareError::Download( + dist, + chain, + err, + )) => { + download(dist, &chain, Box::new(err)); None } - pip::operations::Error::Prepare(uv_installer::PrepareError::Build(dist, err)) => { - build(dist, Box::new(err)); + pip::operations::Error::Prepare(uv_installer::PrepareError::Build( + dist, + chain, + err, + )) => { + build(dist, &chain, Box::new(err)); None } pip::operations::Error::Requirements(err) => { @@ -133,7 +156,7 @@ impl OperationDiagnostic { } /// Render a remote source distribution build failure with a help message. -pub(crate) fn download_and_build(sdist: Box, cause: Error) { +pub(crate) fn download_and_build(sdist: Box, chain: &DerivationChain, cause: Error) { #[derive(Debug, miette::Diagnostic, thiserror::Error)] #[error("Failed to download and build `{sdist}`")] #[diagnostic()] @@ -146,14 +169,32 @@ pub(crate) fn download_and_build(sdist: Box, cause: Error) { } let report = miette::Report::new(Diagnostic { - help: SUGGESTIONS.get(sdist.name()).map(|suggestion| { - format!( - "`{}` is often confused for `{}` Did you mean to install `{}` instead?", - sdist.name().cyan(), - suggestion.cyan(), - suggestion.cyan(), - ) - }), + help: SUGGESTIONS + .get(sdist.name()) + .map(|suggestion| { + format!( + "`{}` is often confused for `{}` Did you mean to install `{}` instead?", + sdist.name().cyan(), + suggestion.cyan(), + suggestion.cyan(), + ) + }) + .or_else(|| { + if chain.is_empty() { + None + } else { + let mut message = format!("`{}` was included because", sdist.name().cyan()); + for (i, step) in chain.iter().enumerate() { + if i == 0 { + message = format!("{message} `{}` depends on", step.cyan()); + } else { + message = format!("{message} `{}` which depends on", step.cyan()); + } + } + message = format!("{message} `{}`", sdist.name().cyan()); + Some(message) + } + }), sdist, cause, }); @@ -161,7 +202,7 @@ pub(crate) fn download_and_build(sdist: Box, cause: Error) { } /// Render a remote binary distribution download failure with a help message. -pub(crate) fn download(sdist: Box, cause: Error) { +pub(crate) fn download(sdist: Box, chain: &DerivationChain, cause: Error) { #[derive(Debug, miette::Diagnostic, thiserror::Error)] #[error("Failed to download `{sdist}`")] #[diagnostic()] @@ -174,14 +215,32 @@ pub(crate) fn download(sdist: Box, cause: Error) { } let report = miette::Report::new(Diagnostic { - help: SUGGESTIONS.get(sdist.name()).map(|suggestion| { - format!( - "`{}` is often confused for `{}` Did you mean to install `{}` instead?", - sdist.name().cyan(), - suggestion.cyan(), - suggestion.cyan(), - ) - }), + help: SUGGESTIONS + .get(sdist.name()) + .map(|suggestion| { + format!( + "`{}` is often confused for `{}` Did you mean to install `{}` instead?", + sdist.name().cyan(), + suggestion.cyan(), + suggestion.cyan(), + ) + }) + .or_else(|| { + if chain.is_empty() { + None + } else { + let mut message = format!("`{}` was included because", sdist.name().cyan()); + for (i, step) in chain.iter().enumerate() { + if i == 0 { + message = format!("{message} `{}` depends on", step.cyan()); + } else { + message = format!("{message} `{}` which depends on", step.cyan()); + } + } + message = format!("{message} `{}`", sdist.name().cyan()); + Some(message) + } + }), sdist, cause, }); @@ -189,7 +248,7 @@ pub(crate) fn download(sdist: Box, cause: Error) { } /// Render a local source distribution build failure with a help message. -pub(crate) fn build(sdist: Box, cause: Error) { +pub(crate) fn build(sdist: Box, chain: &DerivationChain, cause: Error) { #[derive(Debug, miette::Diagnostic, thiserror::Error)] #[error("Failed to build `{sdist}`")] #[diagnostic()] @@ -202,14 +261,32 @@ pub(crate) fn build(sdist: Box, cause: Error) { } let report = miette::Report::new(Diagnostic { - help: SUGGESTIONS.get(sdist.name()).map(|suggestion| { - format!( - "`{}` is often confused for `{}` Did you mean to install `{}` instead?", - sdist.name().cyan(), - suggestion.cyan(), - suggestion.cyan(), - ) - }), + help: SUGGESTIONS + .get(sdist.name()) + .map(|suggestion| { + format!( + "`{}` is often confused for `{}` Did you mean to install `{}` instead?", + sdist.name().cyan(), + suggestion.cyan(), + suggestion.cyan(), + ) + }) + .or_else(|| { + if chain.is_empty() { + None + } else { + let mut message = format!("`{}` was included because", sdist.name().cyan()); + for (i, step) in chain.iter().enumerate() { + if i == 0 { + message = format!("{message} `{}` depends on", step.cyan()); + } else { + message = format!("{message} `{}` which depends on", step.cyan()); + } + } + message = format!("{message} `{}`", sdist.name().cyan()); + Some(message) + } + }), sdist, cause, }); diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index 7f92d7c63a85..a6ea0cabf1fc 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -412,7 +412,7 @@ pub(crate) async fn pip_install( ) .await { - Ok(resolution) => Resolution::from(resolution), + Ok(graph) => Resolution::from(graph), Err(err) => { return diagnostics::OperationDiagnostic::default() .report(err) diff --git a/crates/uv/src/commands/pip/operations.rs b/crates/uv/src/commands/pip/operations.rs index 368357cbcd67..e6b31ce83ba3 100644 --- a/crates/uv/src/commands/pip/operations.rs +++ b/crates/uv/src/commands/pip/operations.rs @@ -36,8 +36,9 @@ use uv_requirements::{ SourceTreeResolver, }; use uv_resolver::{ - DependencyMode, Exclusions, FlatIndex, InMemoryIndex, Manifest, Options, Preference, - Preferences, PythonRequirement, Resolver, ResolverEnvironment, ResolverOutput, + DependencyMode, DerivationChainBuilder, Exclusions, FlatIndex, InMemoryIndex, Manifest, + Options, Preference, Preferences, PythonRequirement, Resolver, ResolverEnvironment, + ResolverOutput, }; use uv_types::{HashStrategy, InFlight, InstalledPackagesProvider}; use uv_warnings::warn_user; @@ -459,7 +460,37 @@ pub(crate) async fn install( ) .with_reporter(PrepareReporter::from(printer).with_length(remote.len() as u64)); - let wheels = preparer.prepare(remote.clone(), in_flight).await?; + let wheels = preparer + .prepare(remote.clone(), in_flight) + .await + .map_err(Error::from) + .map_err(|err| match err { + // Attach resolution context to the error. + Error::Prepare(uv_installer::PrepareError::Download(dist, chain, err)) => { + debug_assert!(chain.is_empty()); + let chain = + DerivationChainBuilder::from_resolution(resolution, (&*dist).into()) + .unwrap_or_default(); + Error::Prepare(uv_installer::PrepareError::Download(dist, chain, err)) + } + Error::Prepare(uv_installer::PrepareError::Build(dist, chain, err)) => { + debug_assert!(chain.is_empty()); + let chain = + DerivationChainBuilder::from_resolution(resolution, (&*dist).into()) + .unwrap_or_default(); + Error::Prepare(uv_installer::PrepareError::Build(dist, chain, err)) + } + Error::Prepare(uv_installer::PrepareError::DownloadAndBuild(dist, chain, err)) => { + debug_assert!(chain.is_empty()); + let chain = + DerivationChainBuilder::from_resolution(resolution, (&*dist).into()) + .unwrap_or_default(); + Error::Prepare(uv_installer::PrepareError::DownloadAndBuild( + dist, chain, err, + )) + } + _ => err, + })?; logger.on_prepare(wheels.len(), start, printer)?; diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 49f5cfe40a33..1b22265aa52d 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -13,7 +13,9 @@ use uv_configuration::{ ExtrasSpecification, HashCheckingMode, InstallOptions, LowerBound, TrustedHost, }; use uv_dispatch::BuildDispatch; -use uv_distribution_types::{DirectorySourceDist, Dist, Index, ResolvedDist, SourceDist}; +use uv_distribution_types::{ + DirectorySourceDist, Dist, Index, Resolution, ResolvedDist, SourceDist, +}; use uv_installer::SitePackages; use uv_normalize::{ExtraName, PackageName}; use uv_pep508::{MarkerTree, Requirement, VersionOrUrl}; @@ -450,9 +452,7 @@ pub(super) async fn do_sync( } /// Filter out any virtual workspace members. -fn apply_no_virtual_project( - resolution: uv_distribution_types::Resolution, -) -> uv_distribution_types::Resolution { +fn apply_no_virtual_project(resolution: Resolution) -> Resolution { resolution.filter(|dist| { let ResolvedDist::Installable { dist, .. } = dist else { return true; @@ -471,10 +471,7 @@ fn apply_no_virtual_project( } /// If necessary, convert any editable requirements to non-editable. -fn apply_editable_mode( - resolution: uv_distribution_types::Resolution, - editable: EditableMode, -) -> uv_distribution_types::Resolution { +fn apply_editable_mode(resolution: Resolution, editable: EditableMode) -> Resolution { match editable { // No modifications are necessary for editable mode; retain any editable distributions. EditableMode::Editable => resolution, diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 5a95afcf8c95..94800a9fe768 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -5573,6 +5573,8 @@ fn fail_to_add_revert_project() -> Result<()> { exec(code, locals()) File "", line 1, in ZeroDivisionError: division by zero + + help: `child` was included because `parent==0.1.0` depends on `child` "###); let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; @@ -5680,6 +5682,8 @@ fn fail_to_edit_revert_project() -> Result<()> { exec(code, locals()) File "", line 1, in ZeroDivisionError: division by zero + + help: `child` was included because `parent==0.1.0` depends on `child` "###); let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 84ede5ce51dd..674ba17e5843 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -6754,6 +6754,7 @@ fn lock_invalid_hash() -> Result<()> { Computed: sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + help: `idna` was included because `project==0.1.0` depends on `anyio==3.7.0` which depends on `idna` "###); Ok(()) @@ -7592,6 +7593,7 @@ fn lock_redact_https() -> Result<()> { × Failed to download `iniconfig==2.0.0` ├─▶ Failed to fetch: `https://pypi-proxy.fly.dev/basic-auth/files/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl` ╰─▶ HTTP status client error (401 Unauthorized) for url (https://pypi-proxy.fly.dev/basic-auth/files/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl) + help: `iniconfig` was included because `foo==0.1.0` depends on `iniconfig` "###); // Installing from the lockfile should fail without an index. @@ -7604,6 +7606,7 @@ fn lock_redact_https() -> Result<()> { × Failed to download `iniconfig==2.0.0` ├─▶ Failed to fetch: `https://pypi-proxy.fly.dev/basic-auth/files/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl` ╰─▶ HTTP status client error (401 Unauthorized) for url (https://pypi-proxy.fly.dev/basic-auth/files/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl) + help: `iniconfig` was included because `foo==0.1.0` depends on `iniconfig` "###); // Installing from the lockfile should succeed when credentials are included on the command-line. @@ -7643,6 +7646,7 @@ fn lock_redact_https() -> Result<()> { × Failed to download `iniconfig==2.0.0` ├─▶ Failed to fetch: `https://pypi-proxy.fly.dev/basic-auth/files/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl` ╰─▶ HTTP status client error (401 Unauthorized) for url (https://pypi-proxy.fly.dev/basic-auth/files/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl) + help: `iniconfig` was included because `foo==0.1.0` depends on `iniconfig` "###); // Installing with credentials from with `UV_INDEX_URL` should succeed. @@ -19223,3 +19227,178 @@ fn lock_dynamic_version() -> Result<()> { Ok(()) } + +#[test] +fn lock_derivation_chain() -> 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 = ["wsgiref"] + "#, + )?; + + let filters = context + .filters() + .into_iter() + .chain([ + (r"exit code: 1", "exit status: 1"), + (r"/.*/src", "/[TMP]/src"), + ]) + .collect::>(); + + uv_snapshot!(filters, context.lock(), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to download and build `wsgiref==0.1.2` + ╰─▶ Build backend failed to determine requirements with `build_wheel()` (exit status: 1) + + [stderr] + Traceback (most recent call last): + File "", line 14, in + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 325, in get_requires_for_build_wheel + return self._get_build_requires(config_settings, requirements=['wheel']) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 295, in _get_build_requires + self.run_setup() + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 487, in run_setup + super().run_setup(setup_script=setup_script) + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 311, in run_setup + exec(code, locals()) + File "", line 5, in + File "[CACHE_DIR]/[TMP]/src/ez_setup/__init__.py", line 170 + print "Setuptools version",version,"or greater has been installed." + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + SyntaxError: Missing parentheses in call to 'print'. Did you mean print(...)? + + help: `wsgiref` was included because `project==0.1.0` depends on `wsgiref` + "###); + + Ok(()) +} + +#[test] +fn lock_derivation_chain_extra() -> 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 = [] + optional-dependencies = { wsgi = ["wsgiref"] } + "#, + )?; + + let filters = context + .filters() + .into_iter() + .chain([ + (r"exit code: 1", "exit status: 1"), + (r"/.*/src", "/[TMP]/src"), + ]) + .collect::>(); + + uv_snapshot!(filters, context.lock(), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to download and build `wsgiref==0.1.2` + ╰─▶ Build backend failed to determine requirements with `build_wheel()` (exit status: 1) + + [stderr] + Traceback (most recent call last): + File "", line 14, in + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 325, in get_requires_for_build_wheel + return self._get_build_requires(config_settings, requirements=['wheel']) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 295, in _get_build_requires + self.run_setup() + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 487, in run_setup + super().run_setup(setup_script=setup_script) + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 311, in run_setup + exec(code, locals()) + File "", line 5, in + File "[CACHE_DIR]/[TMP]/src/ez_setup/__init__.py", line 170 + print "Setuptools version",version,"or greater has been installed." + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + SyntaxError: Missing parentheses in call to 'print'. Did you mean print(...)? + + help: `wsgiref` was included because `project==0.1.0` depends on `wsgiref` + "###); + + Ok(()) +} + +#[test] +fn lock_derivation_chain_group() -> 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 = [] + + [dependency-groups] + wsgi = ["wsgiref"] + "#, + )?; + + let filters = context + .filters() + .into_iter() + .chain([ + (r"exit code: 1", "exit status: 1"), + (r"/.*/src", "/[TMP]/src"), + ]) + .collect::>(); + + uv_snapshot!(filters, context.lock(), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to download and build `wsgiref==0.1.2` + ╰─▶ Build backend failed to determine requirements with `build_wheel()` (exit status: 1) + + [stderr] + Traceback (most recent call last): + File "", line 14, in + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 325, in get_requires_for_build_wheel + return self._get_build_requires(config_settings, requirements=['wheel']) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 295, in _get_build_requires + self.run_setup() + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 487, in run_setup + super().run_setup(setup_script=setup_script) + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 311, in run_setup + exec(code, locals()) + File "", line 5, in + File "[CACHE_DIR]/[TMP]/src/ez_setup/__init__.py", line 170 + print "Setuptools version",version,"or greater has been installed." + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + SyntaxError: Missing parentheses in call to 'print'. Did you mean print(...)? + + help: `wsgiref` was included because `project==0.1.0` depends on `wsgiref` + "###); + + Ok(()) +} diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 01fecac90209..f1a7de1bde93 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -7319,3 +7319,62 @@ fn sklearn() { "# ); } + +#[test] +fn resolve_derivation_chain() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["wsgiref"] + "# + })?; + + let filters = context + .filters() + .into_iter() + .chain([ + (r"exit code: 1", "exit status: 1"), + (r"/.*/src", "/[TMP]/src"), + ]) + .collect::>(); + + uv_snapshot!(filters, context.pip_install() + .arg("-e") + .arg("."), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to download and build `wsgiref==0.1.2` + ╰─▶ Build backend failed to determine requirements with `build_wheel()` (exit status: 1) + + [stderr] + Traceback (most recent call last): + File "", line 14, in + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 325, in get_requires_for_build_wheel + return self._get_build_requires(config_settings, requirements=['wheel']) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 295, in _get_build_requires + self.run_setup() + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 487, in run_setup + super().run_setup(setup_script=setup_script) + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 311, in run_setup + exec(code, locals()) + File "", line 5, in + File "[CACHE_DIR]/[TMP]/src/ez_setup/__init__.py", line 170 + print "Setuptools version",version,"or greater has been installed." + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + SyntaxError: Missing parentheses in call to 'print'. Did you mean print(...)? + + help: `wsgiref` was included because `project==0.1.0` depends on `wsgiref` + "### + ); + + Ok(()) +} diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 99f44e8c3f76..11e593f6192e 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -700,6 +700,8 @@ fn sync_build_isolation_package() -> Result<()> { Traceback (most recent call last): File "", line 8, in ModuleNotFoundError: No module named 'hatchling' + + help: `source-distribution` was included because `project==0.1.0` depends on `source-distribution` "###); // Install `hatchling` for `source-distribution`. @@ -789,6 +791,8 @@ fn sync_build_isolation_extra() -> Result<()> { Traceback (most recent call last): File "", line 8, in ModuleNotFoundError: No module named 'hatchling' + + help: `source-distribution` was included because `project==0.1.0` depends on `source-distribution` "###); // Running `uv sync` with `--all-extras` should also fail. @@ -806,6 +810,8 @@ fn sync_build_isolation_extra() -> Result<()> { Traceback (most recent call last): File "", line 8, in ModuleNotFoundError: No module named 'hatchling' + + help: `source-distribution` was included because `project==0.1.0` depends on `source-distribution` "###); // Install the build dependencies. @@ -4270,3 +4276,196 @@ fn sync_all_groups() -> Result<()> { Ok(()) } + +#[test] +fn sync_derivation_chain() -> 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 = ["wsgiref"] + + [[tool.uv.dependency-metadata]] + name = "wsgiref" + version = "0.1.2" + dependencies = [] + "#, + )?; + + let filters = context + .filters() + .into_iter() + .chain([ + (r"exit code: 1", "exit status: 1"), + (r"/.*/src", "/[TMP]/src"), + ]) + .collect::>(); + + uv_snapshot!(filters, context.sync(), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + × Failed to download and build `wsgiref==0.1.2` + ╰─▶ Build backend failed to determine requirements with `build_wheel()` (exit status: 1) + + [stderr] + Traceback (most recent call last): + File "", line 14, in + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 325, in get_requires_for_build_wheel + return self._get_build_requires(config_settings, requirements=['wheel']) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 295, in _get_build_requires + self.run_setup() + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 487, in run_setup + super().run_setup(setup_script=setup_script) + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 311, in run_setup + exec(code, locals()) + File "", line 5, in + File "[CACHE_DIR]/[TMP]/src/ez_setup/__init__.py", line 170 + print "Setuptools version",version,"or greater has been installed." + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + SyntaxError: Missing parentheses in call to 'print'. Did you mean print(...)? + + help: `wsgiref` was included because `project==0.1.0` depends on `wsgiref` + "###); + + Ok(()) +} + +#[test] +fn sync_derivation_chain_extra() -> 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 = [] + optional-dependencies = { wsgi = ["wsgiref"] } + + [[tool.uv.dependency-metadata]] + name = "wsgiref" + version = "0.1.2" + dependencies = [] + "#, + )?; + + let filters = context + .filters() + .into_iter() + .chain([ + (r"exit code: 1", "exit status: 1"), + (r"/.*/src", "/[TMP]/src"), + ]) + .collect::>(); + + uv_snapshot!(filters, context.sync().arg("--extra").arg("wsgi"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + × Failed to download and build `wsgiref==0.1.2` + ╰─▶ Build backend failed to determine requirements with `build_wheel()` (exit status: 1) + + [stderr] + Traceback (most recent call last): + File "", line 14, in + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 325, in get_requires_for_build_wheel + return self._get_build_requires(config_settings, requirements=['wheel']) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 295, in _get_build_requires + self.run_setup() + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 487, in run_setup + super().run_setup(setup_script=setup_script) + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 311, in run_setup + exec(code, locals()) + File "", line 5, in + File "[CACHE_DIR]/[TMP]/src/ez_setup/__init__.py", line 170 + print "Setuptools version",version,"or greater has been installed." + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + SyntaxError: Missing parentheses in call to 'print'. Did you mean print(...)? + + help: `wsgiref` was included because `project==0.1.0` depends on `wsgiref` + "###); + + Ok(()) +} + +#[test] +fn sync_derivation_chain_group() -> 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 = [] + + [dependency-groups] + wsgi = ["wsgiref"] + + [[tool.uv.dependency-metadata]] + name = "wsgiref" + version = "0.1.2" + dependencies = [] + "#, + )?; + + let filters = context + .filters() + .into_iter() + .chain([ + (r"exit code: 1", "exit status: 1"), + (r"/.*/src", "/[TMP]/src"), + ]) + .collect::>(); + + uv_snapshot!(filters, context.sync().arg("--group").arg("wsgi"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + × Failed to download and build `wsgiref==0.1.2` + ╰─▶ Build backend failed to determine requirements with `build_wheel()` (exit status: 1) + + [stderr] + Traceback (most recent call last): + File "", line 14, in + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 325, in get_requires_for_build_wheel + return self._get_build_requires(config_settings, requirements=['wheel']) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 295, in _get_build_requires + self.run_setup() + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 487, in run_setup + super().run_setup(setup_script=setup_script) + File "[CACHE_DIR]/builds-v0/[TMP]/build_meta.py", line 311, in run_setup + exec(code, locals()) + File "", line 5, in + File "[CACHE_DIR]/[TMP]/src/ez_setup/__init__.py", line 170 + print "Setuptools version",version,"or greater has been installed." + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + SyntaxError: Missing parentheses in call to 'print'. Did you mean print(...)? + + help: `wsgiref` was included because `project==0.1.0` depends on `wsgiref` + "###); + + Ok(()) +}