diff --git a/Cargo.lock b/Cargo.lock index ff72f1418..a9e7c8b15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5538,6 +5538,7 @@ dependencies = [ "tracing-test", "unicode-width 0.2.1", "url", + "uv-cache-key", "uv-fs", "uv-normalize", "uv-pep440", diff --git a/crates/uv-bench/benches/uv.rs b/crates/uv-bench/benches/uv.rs index 2ba7a550c..f9599ea12 100644 --- a/crates/uv-bench/benches/uv.rs +++ b/crates/uv-bench/benches/uv.rs @@ -141,6 +141,9 @@ mod resolver { universal: bool, ) -> Result { let build_isolation = BuildIsolation::default(); + let extra_build_requires = uv_distribution::ExtraBuildRequires { + extra_build_dependencies: uv_workspace::pyproject::ExtraBuildDependencies::default(), + }; let build_options = BuildOptions::default(); let concurrency = Concurrency::default(); let config_settings = ConfigSettings::default(); @@ -187,6 +190,7 @@ mod resolver { &config_settings, &config_settings_package, build_isolation, + &extra_build_requires, LinkMode::default(), &build_options, &hashes, diff --git a/crates/uv-build-frontend/src/lib.rs b/crates/uv-build-frontend/src/lib.rs index b815719ba..092fcec5c 100644 --- a/crates/uv-build-frontend/src/lib.rs +++ b/crates/uv-build-frontend/src/lib.rs @@ -4,6 +4,7 @@ mod error; +use std::borrow::Cow; use std::ffi::OsString; use std::fmt::Formatter; use std::fmt::Write; @@ -42,6 +43,7 @@ use uv_static::EnvVars; use uv_types::{AnyErrorBuild, BuildContext, BuildIsolation, BuildStack, SourceBuildTrait}; use uv_warnings::warn_user_once; use uv_workspace::WorkspaceCache; +use uv_workspace::pyproject::ExtraBuildDependencies; pub use crate::error::{Error, MissingHeaderCause}; @@ -281,6 +283,7 @@ impl SourceBuild { workspace_cache: &WorkspaceCache, config_settings: ConfigSettings, build_isolation: BuildIsolation<'_>, + extra_build_dependencies: &ExtraBuildDependencies, build_stack: &BuildStack, build_kind: BuildKind, mut environment_variables: FxHashMap, @@ -297,7 +300,6 @@ impl SourceBuild { }; let default_backend: Pep517Backend = DEFAULT_BACKEND.clone(); - // Check if we have a PEP 517 build backend. let (pep517_backend, project) = Self::extract_pep517_backend( &source_tree, @@ -322,6 +324,14 @@ impl SourceBuild { .or(fallback_package_version) .cloned(); + let extra_build_dependencies = package_name + .as_ref() + .and_then(|name| extra_build_dependencies.get(name).cloned()) + .unwrap_or_default() + .into_iter() + .map(Requirement::from) + .collect(); + // Create a virtual environment, or install into the shared environment if requested. let venv = if let Some(venv) = build_isolation.shared_environment(package_name.as_ref()) { venv.clone() @@ -349,10 +359,13 @@ impl SourceBuild { source_build_context, &default_backend, &pep517_backend, + extra_build_dependencies, build_stack, ) .await?; + // TODO(zanieb): We'll report `build-system.requires` here but it may include + // `extra-build-dependencies` build_context .install(&resolved_requirements, &venv, build_stack) .await @@ -471,10 +484,13 @@ impl SourceBuild { source_build_context: SourceBuildContext, default_backend: &Pep517Backend, pep517_backend: &Pep517Backend, + extra_build_dependencies: Vec, build_stack: &BuildStack, ) -> Result { Ok( - if pep517_backend.requirements == default_backend.requirements { + if pep517_backend.requirements == default_backend.requirements + && extra_build_dependencies.is_empty() + { let mut resolution = source_build_context.default_resolution.lock().await; if let Some(resolved_requirements) = &*resolution { resolved_requirements.clone() @@ -489,8 +505,21 @@ impl SourceBuild { resolved_requirements } } else { + // TODO(zanieb): It's unclear if we actually want to solve these together. We might + // want to perform a separate solve to allow conflicts? + let requirements = if extra_build_dependencies.is_empty() { + Cow::Borrowed(&pep517_backend.requirements) + } else { + // If there are extra build dependencies, we need to resolve them together with + // the backend requirements. + let mut requirements = pep517_backend.requirements.clone(); + requirements.extend(extra_build_dependencies); + Cow::Owned(requirements) + }; + // TODO(zanieb): We'll report `build-system.requires` here but it may include + // `extra-build-dependencies` build_context - .resolve(&pep517_backend.requirements, build_stack) + .resolve(&requirements, build_stack) .await .map_err(|err| { Error::RequirementsResolve("`build-system.requires`", err.into()) @@ -604,6 +633,7 @@ impl SourceBuild { ); } } + default_backend.clone() }; Ok((backend, pyproject_toml.project)) diff --git a/crates/uv-cli/src/options.rs b/crates/uv-cli/src/options.rs index ce3d8621a..290d9efe5 100644 --- a/crates/uv-cli/src/options.rs +++ b/crates/uv-cli/src/options.rs @@ -347,6 +347,7 @@ pub fn resolver_options( }), no_build_isolation: flag(no_build_isolation, build_isolation, "build-isolation"), no_build_isolation_package: Some(no_build_isolation_package), + extra_build_dependencies: None, exclude_newer, link_mode, no_build: flag(no_build, build, "build"), @@ -465,6 +466,7 @@ pub fn resolver_installer_options( } else { Some(no_build_isolation_package) }, + extra_build_dependencies: None, exclude_newer, link_mode, compile_bytecode: flag(compile_bytecode, no_compile_bytecode, "compile-bytecode"), diff --git a/crates/uv-configuration/src/preview.rs b/crates/uv-configuration/src/preview.rs index a3e2f4ee2..eaf49007a 100644 --- a/crates/uv-configuration/src/preview.rs +++ b/crates/uv-configuration/src/preview.rs @@ -15,6 +15,7 @@ bitflags::bitflags! { const PYLOCK = 1 << 3; const ADD_BOUNDS = 1 << 4; const PREFER_LOCKED_BUILDS = 1 << 5; + const EXTRA_BUILD_DEPENDENCIES = 1 << 6; } } @@ -30,6 +31,7 @@ impl PreviewFeatures { Self::PYLOCK => "pylock", Self::ADD_BOUNDS => "add-bounds", Self::PREFER_LOCKED_BUILDS => "prefer-locked-builds", + Self::EXTRA_BUILD_DEPENDENCIES => "extra-build-dependencies", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -73,6 +75,7 @@ impl FromStr for PreviewFeatures { "pylock" => Self::PYLOCK, "add-bounds" => Self::ADD_BOUNDS, "prefer-locked-builds" => Self::PREFER_LOCKED_BUILDS, + "extra-build-dependencies" => Self::EXTRA_BUILD_DEPENDENCIES, _ => { warn_user_once!("Unknown preview feature: `{part}`"); continue; @@ -239,6 +242,10 @@ mod tests { PreviewFeatures::PREFER_LOCKED_BUILDS.flag_as_str(), "prefer-locked-builds" ); + assert_eq!( + PreviewFeatures::EXTRA_BUILD_DEPENDENCIES.flag_as_str(), + "extra-build-dependencies" + ); } #[test] diff --git a/crates/uv-dispatch/src/lib.rs b/crates/uv-dispatch/src/lib.rs index 5474e8af8..76a84c535 100644 --- a/crates/uv-dispatch/src/lib.rs +++ b/crates/uv-dispatch/src/lib.rs @@ -22,6 +22,7 @@ use uv_configuration::{ }; use uv_configuration::{BuildOutput, Concurrency}; use uv_distribution::DistributionDatabase; +use uv_distribution::ExtraBuildRequires; use uv_distribution_filename::DistFilename; use uv_distribution_types::{ CachedDist, DependencyMetadata, Identifier, IndexCapabilities, IndexLocations, @@ -88,6 +89,7 @@ pub struct BuildDispatch<'a> { shared_state: SharedState, dependency_metadata: &'a DependencyMetadata, build_isolation: BuildIsolation<'a>, + extra_build_requires: &'a ExtraBuildRequires, link_mode: uv_install_wheel::LinkMode, build_options: &'a BuildOptions, config_settings: &'a ConfigSettings, @@ -117,6 +119,7 @@ impl<'a> BuildDispatch<'a> { config_settings: &'a ConfigSettings, config_settings_package: &'a PackageConfigSettings, build_isolation: BuildIsolation<'a>, + extra_build_requires: &'a ExtraBuildRequires, link_mode: uv_install_wheel::LinkMode, build_options: &'a BuildOptions, hasher: &'a HashStrategy, @@ -140,6 +143,7 @@ impl<'a> BuildDispatch<'a> { config_settings, config_settings_package, build_isolation, + extra_build_requires, link_mode, build_options, hasher, @@ -222,6 +226,10 @@ impl BuildContext for BuildDispatch<'_> { &self.workspace_cache } + fn extra_build_dependencies(&self) -> &uv_workspace::pyproject::ExtraBuildDependencies { + &self.extra_build_requires.extra_build_dependencies + } + async fn resolve<'data>( &'data self, requirements: &'data [Requirement], @@ -457,6 +465,7 @@ impl BuildContext for BuildDispatch<'_> { self.workspace_cache(), config_settings, self.build_isolation, + &self.extra_build_requires.extra_build_dependencies, &build_stack, build_kind, self.build_extra_env_vars.clone(), diff --git a/crates/uv-distribution/src/lib.rs b/crates/uv-distribution/src/lib.rs index 07958f715..6371d58af 100644 --- a/crates/uv-distribution/src/lib.rs +++ b/crates/uv-distribution/src/lib.rs @@ -3,8 +3,8 @@ pub use download::LocalWheel; pub use error::Error; pub use index::{BuiltWheelIndex, RegistryWheelIndex}; pub use metadata::{ - ArchiveMetadata, BuildRequires, FlatRequiresDist, LoweredRequirement, LoweringError, Metadata, - MetadataError, RequiresDist, SourcedDependencyGroups, + ArchiveMetadata, BuildRequires, ExtraBuildRequires, FlatRequiresDist, LoweredRequirement, + LoweringError, Metadata, MetadataError, RequiresDist, SourcedDependencyGroups, }; pub use reporter::Reporter; pub use source::prune; diff --git a/crates/uv-distribution/src/metadata/build_requires.rs b/crates/uv-distribution/src/metadata/build_requires.rs index 99b528017..af218b470 100644 --- a/crates/uv-distribution/src/metadata/build_requires.rs +++ b/crates/uv-distribution/src/metadata/build_requires.rs @@ -4,7 +4,8 @@ use std::path::Path; use uv_configuration::SourceStrategy; use uv_distribution_types::{IndexLocations, Requirement}; use uv_normalize::PackageName; -use uv_workspace::pyproject::ToolUvSources; +use uv_pypi_types::VerbatimParsedUrl; +use uv_workspace::pyproject::{ExtraBuildDependencies, ToolUvSources}; use uv_workspace::{ DiscoveryOptions, MemberDiscovery, ProjectWorkspace, Workspace, WorkspaceCache, }; @@ -203,3 +204,93 @@ impl BuildRequires { }) } } + +/// Lowered extra build dependencies with source resolution applied. +#[derive(Debug, Clone)] +pub struct ExtraBuildRequires { + pub extra_build_dependencies: ExtraBuildDependencies, +} + +impl ExtraBuildRequires { + /// Lower extra build dependencies from a workspace, applying source resolution. + pub fn from_workspace( + extra_build_dependencies: ExtraBuildDependencies, + workspace: &Workspace, + index_locations: &IndexLocations, + source_strategy: SourceStrategy, + ) -> Result { + match source_strategy { + SourceStrategy::Enabled => { + // Collect project sources and indexes + let project_indexes = workspace + .pyproject_toml() + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.index.as_deref()) + .unwrap_or(&[]); + + let empty_sources = BTreeMap::default(); + let project_sources = workspace + .pyproject_toml() + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.sources.as_ref()) + .map(ToolUvSources::inner) + .unwrap_or(&empty_sources); + + // Lower each package's extra build dependencies + let mut result = ExtraBuildDependencies::default(); + for (package_name, requirements) in extra_build_dependencies { + let lowered: Vec> = requirements + .into_iter() + .flat_map(|requirement| { + let requirement_name = requirement.name.clone(); + let extra = requirement.marker.top_level_extra_name(); + let group = None; + LoweredRequirement::from_requirement( + requirement, + None, + workspace.install_path(), + project_sources, + project_indexes, + extra.as_deref(), + group, + index_locations, + workspace, + None, + ) + .map( + move |requirement| match requirement { + Ok(requirement) => Ok(requirement.into_inner().into()), + Err(err) => Err(MetadataError::LoweringError( + requirement_name.clone(), + Box::new(err), + )), + }, + ) + }) + .collect::, _>>()?; + result.insert(package_name, lowered); + } + Ok(Self { + extra_build_dependencies: result, + }) + } + SourceStrategy::Disabled => { + // Without source resolution, just return the dependencies as-is + Ok(Self { + extra_build_dependencies, + }) + } + } + } + + /// Create from pre-lowered dependencies (for non-workspace contexts). + pub fn from_lowered(extra_build_dependencies: ExtraBuildDependencies) -> Self { + Self { + extra_build_dependencies, + } + } +} diff --git a/crates/uv-distribution/src/metadata/mod.rs b/crates/uv-distribution/src/metadata/mod.rs index a56a1c354..3375f9fe2 100644 --- a/crates/uv-distribution/src/metadata/mod.rs +++ b/crates/uv-distribution/src/metadata/mod.rs @@ -11,7 +11,7 @@ use uv_pypi_types::{HashDigests, ResolutionMetadata}; use uv_workspace::dependency_groups::DependencyGroupError; use uv_workspace::{WorkspaceCache, WorkspaceError}; -pub use crate::metadata::build_requires::BuildRequires; +pub use crate::metadata::build_requires::{BuildRequires, ExtraBuildRequires}; pub use crate::metadata::dependency_groups::SourcedDependencyGroups; pub use crate::metadata::lowering::LoweredRequirement; pub use crate::metadata::lowering::LoweringError; diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index 66b6122e0..f269c1b87 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -404,6 +404,20 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { } } + /// Determine the extra build dependencies for the given package name. + fn extra_build_dependencies_for( + &self, + name: Option<&PackageName>, + ) -> &[uv_pep508::Requirement] { + name.and_then(|name| { + self.build_context + .extra_build_dependencies() + .get(name) + .map(|v| v.as_slice()) + }) + .unwrap_or(&[]) + } + /// Build a source distribution from a remote URL. async fn url<'data>( &self, @@ -438,12 +452,13 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { let cache_shard = cache_shard.shard(revision.id()); let source_dist_entry = cache_shard.entry(SOURCE); - // If there are build settings, we need to scope to a cache shard. + // If there are build settings or extra build dependencies, we need to scope to a cache shard. let config_settings = self.config_settings_for(source.name()); - let cache_shard = if config_settings.is_empty() { + let extra_build_deps = self.extra_build_dependencies_for(source.name()); + let cache_shard = if config_settings.is_empty() && extra_build_deps.is_empty() { cache_shard } else { - cache_shard.shard(cache_digest(&&config_settings)) + cache_shard.shard(cache_digest(&(&config_settings, extra_build_deps))) }; // If the cache contains a compatible wheel, return it. @@ -614,12 +629,13 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { } } - // If there are build settings, we need to scope to a cache shard. + // If there are build settings or extra build dependencies, we need to scope to a cache shard. let config_settings = self.config_settings_for(source.name()); - let cache_shard = if config_settings.is_empty() { + let extra_build_deps = self.extra_build_dependencies_for(source.name()); + let cache_shard = if config_settings.is_empty() && extra_build_deps.is_empty() { cache_shard } else { - cache_shard.shard(cache_digest(&config_settings)) + cache_shard.shard(cache_digest(&(&config_settings, extra_build_deps))) }; // Otherwise, we either need to build the metadata. @@ -827,12 +843,13 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { let cache_shard = cache_shard.shard(revision.id()); let source_entry = cache_shard.entry(SOURCE); - // If there are build settings, we need to scope to a cache shard. + // If there are build settings or extra build dependencies, we need to scope to a cache shard. let config_settings = self.config_settings_for(source.name()); - let cache_shard = if config_settings.is_empty() { + let extra_build_deps = self.extra_build_dependencies_for(source.name()); + let cache_shard = if config_settings.is_empty() && extra_build_deps.is_empty() { cache_shard } else { - cache_shard.shard(cache_digest(&config_settings)) + cache_shard.shard(cache_digest(&(&config_settings, extra_build_deps))) }; // If the cache contains a compatible wheel, return it. @@ -989,12 +1006,13 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { }); } - // If there are build settings, we need to scope to a cache shard. + // If there are build settings or extra build dependencies, we need to scope to a cache shard. let config_settings = self.config_settings_for(source.name()); - let cache_shard = if config_settings.is_empty() { + let extra_build_deps = self.extra_build_dependencies_for(source.name()); + let cache_shard = if config_settings.is_empty() && extra_build_deps.is_empty() { cache_shard } else { - cache_shard.shard(cache_digest(&config_settings)) + cache_shard.shard(cache_digest(&(&config_settings, extra_build_deps))) }; // Otherwise, we need to build a wheel. @@ -1131,12 +1149,13 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { // freshness, since entries have to be fresher than the revision itself. let cache_shard = cache_shard.shard(revision.id()); - // If there are build settings, we need to scope to a cache shard. + // If there are build settings or extra build dependencies, we need to scope to a cache shard. let config_settings = self.config_settings_for(source.name()); - let cache_shard = if config_settings.is_empty() { + let extra_build_deps = self.extra_build_dependencies_for(source.name()); + let cache_shard = if config_settings.is_empty() && extra_build_deps.is_empty() { cache_shard } else { - cache_shard.shard(cache_digest(&config_settings)) + cache_shard.shard(cache_digest(&(&config_settings, extra_build_deps))) }; // If the cache contains a compatible wheel, return it. @@ -1319,12 +1338,13 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { )); } - // If there are build settings, we need to scope to a cache shard. + // If there are build settings or extra build dependencies, we need to scope to a cache shard. let config_settings = self.config_settings_for(source.name()); - let cache_shard = if config_settings.is_empty() { + let extra_build_deps = self.extra_build_dependencies_for(source.name()); + let cache_shard = if config_settings.is_empty() && extra_build_deps.is_empty() { cache_shard } else { - cache_shard.shard(cache_digest(&config_settings)) + cache_shard.shard(cache_digest(&(&config_settings, extra_build_deps))) }; // Otherwise, we need to build a wheel. @@ -1524,12 +1544,13 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { // Acquire the advisory lock. let _lock = cache_shard.lock().await.map_err(Error::CacheWrite)?; - // If there are build settings, we need to scope to a cache shard. + // If there are build settings or extra build dependencies, we need to scope to a cache shard. let config_settings = self.config_settings_for(source.name()); - let cache_shard = if config_settings.is_empty() { + let extra_build_deps = self.extra_build_dependencies_for(source.name()); + let cache_shard = if config_settings.is_empty() && extra_build_deps.is_empty() { cache_shard } else { - cache_shard.shard(cache_digest(&config_settings)) + cache_shard.shard(cache_digest(&(&config_settings, extra_build_deps))) }; // If the cache contains a compatible wheel, return it. @@ -1827,12 +1848,13 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { )); } - // If there are build settings, we need to scope to a cache shard. + // If there are build settings or extra build dependencies, we need to scope to a cache shard. let config_settings = self.config_settings_for(source.name()); - let cache_shard = if config_settings.is_empty() { + let extra_build_deps = self.extra_build_dependencies_for(source.name()); + let cache_shard = if config_settings.is_empty() && extra_build_deps.is_empty() { cache_shard } else { - cache_shard.shard(cache_digest(&config_settings)) + cache_shard.shard(cache_digest(&(&config_settings, extra_build_deps))) }; // Otherwise, we need to build a wheel. diff --git a/crates/uv-pep440/src/version.rs b/crates/uv-pep440/src/version.rs index 223701692..bd1e0ffe2 100644 --- a/crates/uv-pep440/src/version.rs +++ b/crates/uv-pep440/src/version.rs @@ -114,6 +114,24 @@ impl Operator { pub fn is_star(self) -> bool { matches!(self, Self::EqualStar | Self::NotEqualStar) } + + /// Returns the string representation of this operator. + pub fn as_str(self) -> &'static str { + match self { + Self::Equal => "==", + // Beware, this doesn't print the star + Self::EqualStar => "==", + #[allow(deprecated)] + Self::ExactEqual => "===", + Self::NotEqual => "!=", + Self::NotEqualStar => "!=", + Self::TildeEqual => "~=", + Self::LessThan => "<", + Self::LessThanEqual => "<=", + Self::GreaterThan => ">", + Self::GreaterThanEqual => ">=", + } + } } impl FromStr for Operator { @@ -150,21 +168,7 @@ impl FromStr for Operator { impl std::fmt::Display for Operator { /// Note the `EqualStar` is also `==`. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let operator = match self { - Self::Equal => "==", - // Beware, this doesn't print the star - Self::EqualStar => "==", - #[allow(deprecated)] - Self::ExactEqual => "===", - Self::NotEqual => "!=", - Self::NotEqualStar => "!=", - Self::TildeEqual => "~=", - Self::LessThan => "<", - Self::LessThanEqual => "<=", - Self::GreaterThan => ">", - Self::GreaterThanEqual => ">=", - }; - + let operator = self.as_str(); write!(f, "{operator}") } } diff --git a/crates/uv-pep440/src/version_specifier.rs b/crates/uv-pep440/src/version_specifier.rs index e111c5118..13e2687cf 100644 --- a/crates/uv-pep440/src/version_specifier.rs +++ b/crates/uv-pep440/src/version_specifier.rs @@ -48,6 +48,11 @@ impl VersionSpecifiers { Self(Box::new([])) } + /// The number of specifiers. + pub fn len(&self) -> usize { + self.0.len() + } + /// Whether all specifiers match the given version. pub fn contains(&self, version: &Version) -> bool { self.iter().all(|specifier| specifier.contains(version)) diff --git a/crates/uv-pep508/Cargo.toml b/crates/uv-pep508/Cargo.toml index cb23cfc04..c4830043b 100644 --- a/crates/uv-pep508/Cargo.toml +++ b/crates/uv-pep508/Cargo.toml @@ -19,6 +19,7 @@ doctest = false workspace = true [dependencies] +uv-cache-key = { workspace = true } uv-fs = { workspace = true } uv-normalize = { workspace = true } uv-pep440 = { workspace = true } diff --git a/crates/uv-pep508/src/lib.rs b/crates/uv-pep508/src/lib.rs index 10e4142e7..b26742975 100644 --- a/crates/uv-pep508/src/lib.rs +++ b/crates/uv-pep508/src/lib.rs @@ -26,6 +26,7 @@ use std::str::FromStr; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use thiserror::Error; use url::Url; +use uv_cache_key::{CacheKey, CacheKeyHasher}; use cursor::Cursor; pub use marker::{ @@ -251,6 +252,52 @@ impl Serialize for Requirement { } } +impl CacheKey for Requirement +where + T: Display, +{ + fn cache_key(&self, state: &mut CacheKeyHasher) { + self.name.as_str().cache_key(state); + + self.extras.len().cache_key(state); + for extra in &self.extras { + extra.as_str().cache_key(state); + } + + // TODO(zanieb): We inline cache key handling for the child types here, but we could + // move the implementations to the children. The intent here was to limit the scope of + // types exposing the `CacheKey` trait for now. + if let Some(version_or_url) = &self.version_or_url { + 1u8.cache_key(state); + match version_or_url { + VersionOrUrl::VersionSpecifier(spec) => { + 0u8.cache_key(state); + spec.len().cache_key(state); + for specifier in spec.iter() { + specifier.operator().as_str().cache_key(state); + specifier.version().to_string().cache_key(state); + } + } + VersionOrUrl::Url(url) => { + 1u8.cache_key(state); + url.to_string().cache_key(state); + } + } + } else { + 0u8.cache_key(state); + } + + if let Some(marker) = self.marker.contents() { + 1u8.cache_key(state); + marker.to_string().cache_key(state); + } else { + 0u8.cache_key(state); + } + + // `origin` is intentionally omitted + } +} + impl Requirement { /// Returns whether the markers apply for the given environment pub fn evaluate_markers(&self, env: &MarkerEnvironment, extras: &[ExtraName]) -> bool { diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index b80cdc219..b0b9fde2e 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -381,6 +381,8 @@ pub struct ToolUv { pub override_dependencies: Option>>, pub constraint_dependencies: Option>>, pub build_constraint_dependencies: Option>>, + pub extra_build_dependencies: + Option>>>, pub sources: Option>, } diff --git a/crates/uv-settings/src/combine.rs b/crates/uv-settings/src/combine.rs index b8d2e3c28..8af9db328 100644 --- a/crates/uv-settings/src/combine.rs +++ b/crates/uv-settings/src/combine.rs @@ -1,5 +1,5 @@ -use std::num::NonZeroUsize; use std::path::PathBuf; +use std::{collections::BTreeMap, num::NonZeroUsize}; use url::Url; @@ -121,6 +121,21 @@ impl Combine for Option> { } } +impl Combine for Option>> { + /// Combine two maps of vecs by combining their vecs + fn combine(self, other: Option>>) -> Option>> { + match (self, other) { + (Some(mut a), Some(b)) => { + for (key, value) in b { + a.entry(key).or_default().extend(value); + } + Some(a) + } + (a, b) => a.or(b), + } + } +} + impl Combine for Option { /// Combine two maps by merging the map in `self` with the map in `other`, if they're both /// `Some`. diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index d56d512e0..c614880cb 100644 --- a/crates/uv-settings/src/lib.rs +++ b/crates/uv-settings/src/lib.rs @@ -317,6 +317,7 @@ fn warn_uv_toml_masked_fields(options: &Options) { config_settings_package, no_build_isolation, no_build_isolation_package, + extra_build_dependencies, exclude_newer, link_mode, compile_bytecode, @@ -445,6 +446,9 @@ fn warn_uv_toml_masked_fields(options: &Options) { if no_build_isolation_package.is_some() { masked_fields.push("no-build-isolation-package"); } + if extra_build_dependencies.is_some() { + masked_fields.push("extra-build-dependencies"); + } if exclude_newer.is_some() { masked_fields.push("exclude-newer"); } diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 254cb204b..6515f5835 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -21,7 +21,7 @@ use uv_redacted::DisplaySafeUrl; use uv_resolver::{AnnotationStyle, ExcludeNewer, ForkStrategy, PrereleaseMode, ResolutionMode}; use uv_static::EnvVars; use uv_torch::TorchMode; -use uv_workspace::pyproject_mut::AddBoundsKind; +use uv_workspace::{pyproject::ExtraBuildDependencies, pyproject_mut::AddBoundsKind}; /// A `pyproject.toml` with an (optional) `[tool.uv]` section. #[allow(dead_code)] @@ -373,6 +373,7 @@ pub struct ResolverOptions { pub no_binary_package: Option>, pub no_build_isolation: Option, pub no_build_isolation_package: Option>, + pub extra_build_dependencies: Option, pub no_sources: Option, pub build_dependency_strategy: Option, } @@ -643,6 +644,20 @@ pub struct ResolverInstallerOptions { "# )] pub no_build_isolation_package: Option>, + /// Additional build dependencies for packages. + /// + /// This allows extending the PEP 517 build environment for the project's dependencies with + /// additional packages. This is useful for packages that assume the presence of packages, like, + /// `pip`, and do not declare them as build dependencies. + #[option( + default = "[]", + value_type = "dict", + example = r#" + [extra-build-dependencies] + pytest = ["setuptools"] + "# + )] + pub extra_build_dependencies: Option, /// Limit candidate packages to those that were uploaded prior to a given point in time. /// /// Accepts a superset of [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339.html) (e.g., @@ -1139,6 +1154,20 @@ pub struct PipOptions { "# )] pub no_build_isolation_package: Option>, + /// Additional build dependencies for packages. + /// + /// This allows extending the PEP 517 build environment for the project's dependencies with + /// additional packages. This is useful for packages that assume the presence of packages, like, + /// `pip`, and do not declare them as build dependencies. + #[option( + default = "[]", + value_type = "dict", + example = r#" + [extra-build-dependencies] + pytest = ["setuptools"] + "# + )] + pub extra_build_dependencies: Option, /// Validate the Python environment, to detect packages with missing dependencies and other /// issues. #[option( @@ -1704,6 +1733,7 @@ impl From for ResolverOptions { no_binary_package: value.no_binary_package, no_build_isolation: value.no_build_isolation, no_build_isolation_package: value.no_build_isolation_package, + extra_build_dependencies: value.extra_build_dependencies, no_sources: value.no_sources, build_dependency_strategy: value.build_dependency_strategy, } @@ -1761,6 +1791,7 @@ pub struct ToolOptions { pub config_settings_package: Option, pub no_build_isolation: Option, pub no_build_isolation_package: Option>, + pub extra_build_dependencies: Option, pub exclude_newer: Option, pub link_mode: Option, pub compile_bytecode: Option, @@ -1789,6 +1820,7 @@ impl From for ToolOptions { config_settings_package: value.config_settings_package, no_build_isolation: value.no_build_isolation, no_build_isolation_package: value.no_build_isolation_package, + extra_build_dependencies: value.extra_build_dependencies, exclude_newer: value.exclude_newer, link_mode: value.link_mode, compile_bytecode: value.compile_bytecode, @@ -1819,6 +1851,7 @@ impl From for ResolverInstallerOptions { config_settings_package: value.config_settings_package, no_build_isolation: value.no_build_isolation, no_build_isolation_package: value.no_build_isolation_package, + extra_build_dependencies: value.extra_build_dependencies, exclude_newer: value.exclude_newer, link_mode: value.link_mode, compile_bytecode: value.compile_bytecode, @@ -1873,6 +1906,7 @@ pub struct OptionsWire { config_settings_package: Option, no_build_isolation: Option, no_build_isolation_package: Option>, + extra_build_dependencies: Option, exclude_newer: Option, link_mode: Option, compile_bytecode: Option, @@ -1992,6 +2026,7 @@ impl From for Options { sources, default_groups, dependency_groups, + extra_build_dependencies, dev_dependencies, managed, package, @@ -2033,6 +2068,7 @@ impl From for Options { config_settings_package, no_build_isolation, no_build_isolation_package, + extra_build_dependencies, exclude_newer, link_mode, compile_bytecode, diff --git a/crates/uv-types/src/traits.rs b/crates/uv-types/src/traits.rs index e3f4ee012..875742da9 100644 --- a/crates/uv-types/src/traits.rs +++ b/crates/uv-types/src/traits.rs @@ -101,6 +101,9 @@ pub trait BuildContext { /// Workspace discovery caching. fn workspace_cache(&self) -> &WorkspaceCache; + /// Get the extra build dependencies. + fn extra_build_dependencies(&self) -> &uv_workspace::pyproject::ExtraBuildDependencies; + /// Resolve the given requirements into a ready-to-install set of package versions. fn resolve<'a>( &'a self, diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index b02dadc5d..4a1c9231c 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -378,6 +378,21 @@ pub struct ToolUv { )] pub dependency_groups: Option, + /// Additional build dependencies for packages. + /// + /// This allows extending the PEP 517 build environment for the project's dependencies with + /// additional packages. This is useful for packages that assume the presence of packages, like, + /// `pip`, and do not declare them as build dependencies. + #[option( + default = "[]", + value_type = "dict", + example = r#" + [tool.uv.extra-build-dependencies] + pytest = ["pip"] + "# + )] + pub extra_build_dependencies: Option, + /// The project's development dependencies. /// /// Development dependencies will be installed by default in `uv run` and `uv sync`, but will @@ -749,6 +764,70 @@ pub struct DependencyGroupSettings { pub requires_python: Option, } +pub type ExtraBuildDependencies = + BTreeMap>>; + +#[derive(Default, Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(Serialize))] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ToolUvExtraBuildDependencies(ExtraBuildDependencies); + +impl ToolUvExtraBuildDependencies { + /// Returns the underlying `BTreeMap` of group names to settings. + pub fn inner(&self) -> &ExtraBuildDependencies { + &self.0 + } + + /// Convert the [`ToolUvExtraBuildDependencies`] into its inner `BTreeMap`. + #[must_use] + pub fn into_inner(self) -> ExtraBuildDependencies { + self.0 + } +} + +/// Ensure that all keys in the TOML table are unique. +impl<'de> serde::de::Deserialize<'de> for ToolUvExtraBuildDependencies { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct DependenciesVisitor; + + impl<'de> serde::de::Visitor<'de> for DependenciesVisitor { + type Value = ToolUvExtraBuildDependencies; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a map with unique keys") + } + + fn visit_map(self, mut access: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + let mut groups = BTreeMap::new(); + while let Some((key, value)) = access + .next_entry::>>()? + { + match groups.entry(key) { + std::collections::btree_map::Entry::Occupied(entry) => { + return Err(serde::de::Error::custom(format!( + "duplicate extra-build-dependencies for `{}`", + entry.key() + ))); + } + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(value); + } + } + } + Ok(ToolUvExtraBuildDependencies(groups)) + } + } + + deserializer.deserialize_map(DependenciesVisitor) + } +} + #[derive(Deserialize, OptionsMetadata, Default, Debug, Clone, PartialEq, Eq)] #[cfg_attr(test, derive(Serialize))] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 09f2b692a..53342865e 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -1970,6 +1970,7 @@ mod tests { "package": null, "default-groups": null, "dependency-groups": null, + "extra-build-dependencies": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, @@ -2070,6 +2071,7 @@ mod tests { "package": null, "default-groups": null, "dependency-groups": null, + "extra-build-dependencies": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, @@ -2283,6 +2285,7 @@ mod tests { "package": null, "default-groups": null, "dependency-groups": null, + "extra-build-dependencies": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, @@ -2392,6 +2395,7 @@ mod tests { "package": null, "default-groups": null, "dependency-groups": null, + "extra-build-dependencies": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, @@ -2514,6 +2518,7 @@ mod tests { "package": null, "default-groups": null, "dependency-groups": null, + "extra-build-dependencies": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, @@ -2610,6 +2615,7 @@ mod tests { "package": null, "default-groups": null, "dependency-groups": null, + "extra-build-dependencies": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index c817bc3e5..420ad4253 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -38,6 +38,7 @@ use uv_requirements::RequirementsSource; use uv_resolver::{ExcludeNewer, FlatIndex, Preferences}; use uv_settings::PythonInstallMirrors; use uv_types::{AnyErrorBuild, BuildContext, BuildIsolation, BuildStack, HashStrategy}; +use uv_workspace::pyproject::ExtraBuildDependencies; use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache, WorkspaceError}; use crate::commands::ExitStatus; @@ -200,6 +201,7 @@ async fn build_impl( config_settings_package, no_build_isolation, no_build_isolation_package, + extra_build_dependencies, exclude_newer, link_mode, upgrade: _, @@ -347,6 +349,7 @@ async fn build_impl( build_constraints, *no_build_isolation, no_build_isolation_package, + extra_build_dependencies, *index_strategy, *keyring_provider, *exclude_newer, @@ -425,6 +428,7 @@ async fn build_package( build_constraints: &[RequirementsSource], no_build_isolation: bool, no_build_isolation_package: &[PackageName], + extra_build_dependencies: &ExtraBuildDependencies, index_strategy: IndexStrategy, keyring_provider: KeyringProviderType, exclude_newer: Option, @@ -561,6 +565,8 @@ async fn build_package( let workspace_cache = WorkspaceCache::default(); // Create a build dispatch. + let extra_build_requires = + uv_distribution::ExtraBuildRequires::from_lowered(extra_build_dependencies.clone()); let build_dispatch = BuildDispatch::new( &client, cache, @@ -574,6 +580,7 @@ async fn build_package( config_setting, config_settings_package, build_isolation, + &extra_build_requires, link_mode, build_options, &hasher, diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index 156905ff1..49579b4da 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -14,8 +14,8 @@ use uv_cache::Cache; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ BuildOptions, Concurrency, ConfigSettings, Constraints, ExportFormat, ExtrasSpecification, - IndexStrategy, NoBinary, NoBuild, PackageConfigSettings, Preview, Reinstall, SourceStrategy, - Upgrade, + IndexStrategy, NoBinary, NoBuild, PackageConfigSettings, Preview, PreviewFeatures, Reinstall, + SourceStrategy, Upgrade, }; use uv_configuration::{KeyringProviderType, TargetTriple}; use uv_dispatch::{BuildDispatch, SharedState}; @@ -44,8 +44,9 @@ use uv_resolver::{ }; use uv_torch::{TorchMode, TorchStrategy}; use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy}; -use uv_warnings::warn_user; +use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::WorkspaceCache; +use uv_workspace::pyproject::ExtraBuildDependencies; use crate::commands::pip::loggers::DefaultResolveLogger; use crate::commands::pip::{operations, resolution_environment}; @@ -95,6 +96,7 @@ pub(crate) async fn pip_compile( config_settings_package: PackageConfigSettings, no_build_isolation: bool, no_build_isolation_package: Vec, + extra_build_dependencies: &ExtraBuildDependencies, build_options: BuildOptions, mut python_version: Option, python_platform: Option, @@ -112,6 +114,15 @@ pub(crate) async fn pip_compile( printer: Printer, preview: Preview, ) -> Result { + if !preview.is_enabled(PreviewFeatures::EXTRA_BUILD_DEPENDENCIES) + && !extra_build_dependencies.is_empty() + { + warn_user_once!( + "The `extra-build-dependencies` option is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::EXTRA_BUILD_DEPENDENCIES + ); + } + // If the user provides a `pyproject.toml` or other TOML file as the output file, raise an // error. if output_file @@ -469,6 +480,8 @@ pub(crate) async fn pip_compile( .map(|constraint| constraint.requirement.clone()), ); + let extra_build_requires = + uv_distribution::ExtraBuildRequires::from_lowered(extra_build_dependencies.clone()); let build_dispatch = BuildDispatch::new( &client, &cache, @@ -482,6 +495,7 @@ pub(crate) async fn pip_compile( &config_settings, &config_settings_package, build_isolation, + &extra_build_requires, link_mode, &build_options, &build_hashes, diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index 9b7f85c9d..8e081819c 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -36,8 +36,9 @@ use uv_resolver::{ }; use uv_torch::{TorchMode, TorchStrategy}; use uv_types::{BuildIsolation, HashStrategy}; -use uv_warnings::warn_user; +use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::WorkspaceCache; +use uv_workspace::pyproject::ExtraBuildDependencies; use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger, InstallLogger}; use crate::commands::pip::operations::Modifications; @@ -78,6 +79,7 @@ pub(crate) async fn pip_install( config_settings_package: &PackageConfigSettings, no_build_isolation: bool, no_build_isolation_package: Vec, + extra_build_dependencies: &ExtraBuildDependencies, build_options: BuildOptions, modifications: Modifications, python_version: Option, @@ -99,6 +101,15 @@ pub(crate) async fn pip_install( ) -> anyhow::Result { let start = std::time::Instant::now(); + if !preview.is_enabled(PreviewFeatures::EXTRA_BUILD_DEPENDENCIES) + && !extra_build_dependencies.is_empty() + { + warn_user_once!( + "The `extra-build-dependencies` option is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::EXTRA_BUILD_DEPENDENCIES + ); + } + let client_builder = BaseClientBuilder::new() .retries_from_env()? .connectivity(network_settings.connectivity) @@ -413,6 +424,8 @@ pub(crate) async fn pip_install( let state = SharedState::default(); // Create a build dispatch. + let extra_build_requires = + uv_distribution::ExtraBuildRequires::from_lowered(extra_build_dependencies.clone()); let build_dispatch = BuildDispatch::new( &client, &cache, @@ -426,6 +439,7 @@ pub(crate) async fn pip_install( config_settings, config_settings_package, build_isolation, + &extra_build_requires, link_mode, &build_options, &build_hasher, diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index a7bd36652..562641ecf 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -32,8 +32,9 @@ use uv_resolver::{ }; use uv_torch::{TorchMode, TorchStrategy}; use uv_types::{BuildIsolation, HashStrategy}; -use uv_warnings::warn_user; +use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::WorkspaceCache; +use uv_workspace::pyproject::ExtraBuildDependencies; use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger}; use crate::commands::pip::operations::Modifications; @@ -67,6 +68,7 @@ pub(crate) async fn pip_sync( config_settings_package: &PackageConfigSettings, no_build_isolation: bool, no_build_isolation_package: Vec, + extra_build_dependencies: &ExtraBuildDependencies, build_options: BuildOptions, python_version: Option, python_platform: Option, @@ -85,6 +87,15 @@ pub(crate) async fn pip_sync( printer: Printer, preview: Preview, ) -> Result { + if !preview.is_enabled(PreviewFeatures::EXTRA_BUILD_DEPENDENCIES) + && !extra_build_dependencies.is_empty() + { + warn_user_once!( + "The `extra-build-dependencies` option is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::EXTRA_BUILD_DEPENDENCIES + ); + } + let client_builder = BaseClientBuilder::new() .retries_from_env()? .connectivity(network_settings.connectivity) @@ -348,6 +359,8 @@ pub(crate) async fn pip_sync( let state = SharedState::default(); // Create a build dispatch. + let extra_build_requires = + uv_distribution::ExtraBuildRequires::from_lowered(extra_build_dependencies.clone()); let build_dispatch = BuildDispatch::new( &client, &cache, @@ -361,6 +374,7 @@ pub(crate) async fn pip_sync( config_settings, config_settings_package, build_isolation, + &extra_build_requires, link_mode, &build_options, &build_hasher, diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index a66d2b517..cc8e71b3d 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -104,6 +104,15 @@ pub(crate) async fn add( ); } + if !preview.is_enabled(PreviewFeatures::EXTRA_BUILD_DEPENDENCIES) + && !settings.resolver.extra_build_dependencies.is_empty() + { + warn_user_once!( + "The `extra-build-dependencies` option is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::EXTRA_BUILD_DEPENDENCIES + ); + } + for source in &requirements { match source { RequirementsSource::PyprojectToml(_) => { @@ -457,6 +466,18 @@ pub(crate) async fn add( }; // Create a build dispatch. + let extra_build_requires = if let AddTarget::Project(project, _) = &target { + uv_distribution::ExtraBuildRequires::from_workspace( + settings.resolver.extra_build_dependencies.clone(), + project.workspace(), + &settings.resolver.index_locations, + settings.resolver.sources, + )? + } else { + uv_distribution::ExtraBuildRequires::from_lowered( + settings.resolver.extra_build_dependencies.clone(), + ) + }; let build_dispatch = BuildDispatch::new( &client, cache, @@ -470,6 +491,7 @@ pub(crate) async fn add( &settings.resolver.config_setting, &settings.resolver.config_settings_package, build_isolation, + &extra_build_requires, settings.resolver.link_mode, &settings.resolver.build_options, &build_hasher, @@ -1312,6 +1334,7 @@ impl PythonTarget { /// Represents the destination where dependencies are added, either to a project or a script. #[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] pub(super) enum AddTarget { /// A PEP 723 script, with inline metadata. Script(Pep723Script, Box), @@ -1420,6 +1443,7 @@ impl AddTarget { } #[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] enum AddTargetSnapshot { Script(Pep723Script, Option>), Project(VirtualProject, Option>), diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 6b06e493a..df839be08 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -32,6 +32,7 @@ use crate::printer::Printer; use crate::settings::{NetworkSettings, ResolverSettings}; #[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] enum ExportTarget { /// A PEP 723 script, with inline metadata. Script(Pep723Script), diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index e4848daba..b15b5721a 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -42,7 +42,7 @@ use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, Summary use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ ProjectError, ProjectInterpreter, ScriptInterpreter, UniversalState, - init_script_python_requirement, + init_script_python_requirement, script_specification, }; use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter}; use crate::commands::{ExitStatus, ScriptPath, diagnostics, pip}; @@ -435,6 +435,7 @@ async fn do_lock( config_settings_package, no_build_isolation, no_build_isolation_package, + extra_build_dependencies, exclude_newer, link_mode, upgrade, @@ -453,6 +454,15 @@ async fn do_lock( ); } + if !preview.is_enabled(PreviewFeatures::EXTRA_BUILD_DEPENDENCIES) + && !extra_build_dependencies.is_empty() + { + warn_user_once!( + "The `extra-build-dependencies` option is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::EXTRA_BUILD_DEPENDENCIES + ); + } + // Collect the requirements, etc. let members = target.members(); let packages = target.packages(); @@ -695,6 +705,24 @@ async fn do_lock( }; // Create a build dispatch. + let extra_build_requires = match &target { + LockTarget::Workspace(workspace) => uv_distribution::ExtraBuildRequires::from_workspace( + extra_build_dependencies.clone(), + workspace, + index_locations, + *sources, + )?, + LockTarget::Script(script) => { + // Try to get extra build dependencies from the script metadata + script_specification(Pep723ItemRef::Script(script), settings)? + .map(|spec| spec.extra_build_requires) + .unwrap_or_else(|| { + uv_distribution::ExtraBuildRequires::from_lowered( + extra_build_dependencies.clone(), + ) + }) + } + }; let build_dispatch = BuildDispatch::new( &client, cache, @@ -708,6 +736,7 @@ async fn do_lock( config_setting, config_settings_package, build_isolation, + &extra_build_requires, *link_mode, build_options, &build_hasher, diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 2579030ee..62cc66747 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -46,6 +46,7 @@ use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_virtualenv::remove_virtualenv; use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::dependency_groups::DependencyGroupError; +use uv_workspace::pyproject::ExtraBuildDependencies; use uv_workspace::pyproject::PyProjectToml; use uv_workspace::{RequiresPythonSources, Workspace, WorkspaceCache}; @@ -1692,6 +1693,7 @@ pub(crate) async fn resolve_names( link_mode, no_build_isolation, no_build_isolation_package, + extra_build_dependencies, prerelease: _, resolution: _, sources, @@ -1741,6 +1743,8 @@ pub(crate) async fn resolve_names( let build_hasher = HashStrategy::default(); // Create a build dispatch. + let extra_build_requires = + uv_distribution::ExtraBuildRequires::from_lowered(extra_build_dependencies.clone()); let build_dispatch = BuildDispatch::new( &client, cache, @@ -1754,6 +1758,7 @@ pub(crate) async fn resolve_names( config_setting, config_settings_package, build_isolation, + &extra_build_requires, *link_mode, build_options, &build_hasher, @@ -1847,6 +1852,7 @@ pub(crate) async fn resolve_environment( config_settings_package, no_build_isolation, no_build_isolation_package, + extra_build_dependencies, exclude_newer, link_mode, upgrade: _, @@ -1951,6 +1957,8 @@ pub(crate) async fn resolve_environment( let workspace_cache = WorkspaceCache::default(); // Create a build dispatch. + let extra_build_requires = + uv_distribution::ExtraBuildRequires::from_lowered(extra_build_dependencies.clone()); let resolve_dispatch = BuildDispatch::new( &client, cache, @@ -1964,6 +1972,7 @@ pub(crate) async fn resolve_environment( config_setting, config_settings_package, build_isolation, + &extra_build_requires, *link_mode, build_options, &build_hasher, @@ -2032,6 +2041,7 @@ pub(crate) async fn sync_environment( config_settings_package, no_build_isolation, no_build_isolation_package, + extra_build_dependencies, exclude_newer, link_mode, compile_bytecode, @@ -2091,6 +2101,8 @@ pub(crate) async fn sync_environment( }; // Create a build dispatch. + let extra_build_requires = + uv_distribution::ExtraBuildRequires::from_lowered(extra_build_dependencies.clone()); let build_dispatch = BuildDispatch::new( &client, cache, @@ -2104,6 +2116,7 @@ pub(crate) async fn sync_environment( config_setting, config_settings_package, build_isolation, + &extra_build_requires, link_mode, build_options, &build_hasher, @@ -2148,6 +2161,15 @@ pub(crate) async fn sync_environment( Ok(venv) } +/// A script specification that includes both requirements and extra build dependencies. +#[derive(Debug)] +pub(crate) struct ScriptSpecification { + /// The requirements specification for the script. + pub(crate) requirements: RequirementsSpecification, + /// The extra build dependencies for the script. + pub(crate) extra_build_requires: uv_distribution::ExtraBuildRequires, +} + /// The result of updating a [`PythonEnvironment`] to satisfy a set of [`RequirementsSource`]s. #[derive(Debug)] pub(crate) struct EnvironmentUpdate { @@ -2170,6 +2192,7 @@ pub(crate) async fn update_environment( spec: RequirementsSpecification, modifications: Modifications, build_constraints: Constraints, + extra_build_requires: uv_distribution::ExtraBuildRequires, settings: &ResolverInstallerSettings, network_settings: &NetworkSettings, state: &SharedState, @@ -2200,6 +2223,7 @@ pub(crate) async fn update_environment( link_mode, no_build_isolation, no_build_isolation_package, + extra_build_dependencies: _, prerelease, resolution, sources, @@ -2330,6 +2354,7 @@ pub(crate) async fn update_environment( config_setting, config_settings_package, build_isolation, + &extra_build_requires, *link_mode, build_options, &build_hasher, @@ -2535,12 +2560,12 @@ pub(crate) fn detect_conflicts( Ok(()) } -/// Determine the [`RequirementsSpecification`] for a script. +/// Determine the [`ScriptSpecification`] for a script. #[allow(clippy::result_large_err)] pub(crate) fn script_specification( script: Pep723ItemRef<'_>, settings: &ResolverSettings, -) -> Result, ProjectError> { +) -> Result, ProjectError> { let Some(dependencies) = script.metadata().dependencies.as_ref() else { return Ok(None); }; @@ -2635,11 +2660,47 @@ pub(crate) fn script_specification( }) .collect::, _>>()?; - Ok(Some(RequirementsSpecification::from_overrides( - requirements, - constraints, - overrides, - ))) + // Collect any `tool.uv.extra-build-dependencies` from the script. + let empty = BTreeMap::default(); + let script_extra_build_dependencies = script + .metadata() + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.extra_build_dependencies.as_ref()) + .unwrap_or(&empty); + + // Lower the extra build dependencies + let mut extra_build_dependencies = ExtraBuildDependencies::default(); + for (name, requirements) in script_extra_build_dependencies { + let lowered_requirements: Vec<_> = requirements + .iter() + .cloned() + .flat_map(|requirement| { + LoweredRequirement::from_non_workspace_requirement( + requirement, + script_dir.as_ref(), + script_sources, + script_indexes, + &settings.index_locations, + ) + .map_ok(|req| req.into_inner().into()) + }) + .collect::, _>>()?; + extra_build_dependencies.insert(name.clone(), lowered_requirements); + } + + let extra_build_requires = + uv_distribution::ExtraBuildRequires::from_lowered(extra_build_dependencies); + + Ok(Some(ScriptSpecification { + requirements: RequirementsSpecification::from_overrides( + requirements, + constraints, + overrides, + ), + extra_build_requires, + })) } /// Warn if the user provides (e.g.) an `--index-url` in a requirements file. diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 1af4c818f..50c498833 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -386,6 +386,7 @@ pub(crate) async fn remove( /// Represents the destination where dependencies are added, either to a project or a script. #[derive(Debug)] +#[allow(clippy::large_enum_variant)] enum RemoveTarget { /// A PEP 723 script, with inline metadata. Project(VirtualProject), diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 20e11db18..c50788ad8 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -358,7 +358,9 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl } // Install the script requirements, if necessary. Otherwise, use an isolated environment. - if let Some(spec) = script_specification((&script).into(), &settings.resolver)? { + if let Some(script_spec) = script_specification((&script).into(), &settings.resolver)? { + let spec = script_spec.requirements; + let script_extra_build_requires = script_spec.extra_build_requires; let environment = ScriptEnvironment::get_or_init( (&script).into(), python.as_deref().map(PythonRequest::parse), @@ -407,6 +409,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl spec, modifications, build_constraints.unwrap_or_default(), + script_extra_build_requires, &settings, &network_settings, &sync_state, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 9794c109e..2e80c3838 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -15,7 +15,7 @@ use uv_configuration::{ BuildDependencyStrategy, Concurrency, Constraints, DependencyGroups, DependencyGroupsWithDefaults, DryRun, EditableMode, ExtrasSpecification, ExtrasSpecificationWithDefaults, HashCheckingMode, InstallOptions, Preview, PreviewFeatures, - TargetTriple, + TargetTriple, Upgrade, }; use uv_dispatch::BuildDispatch; use uv_distribution_types::{ @@ -27,12 +27,13 @@ use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; use uv_pep508::{MarkerTree, VersionOrUrl}; use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl, ParsedUrl}; use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; -use uv_resolver::{FlatIndex, Installable, Lock, Preference, Preferences, ResolverEnvironment}; +use uv_requirements::RequirementsSpecification; +use uv_resolver::{FlatIndex, ForkStrategy, Installable, Lock, Preference, Preferences, PrereleaseMode, ResolutionMode, ResolverEnvironment}; use uv_scripts::{Pep723ItemRef, Pep723Script}; use uv_settings::PythonInstallMirrors; use uv_types::{BuildIsolation, HashStrategy}; use uv_warnings::{warn_user, warn_user_once}; -use uv_workspace::pyproject::Source; +use uv_workspace::pyproject::{ExtraBuildDependencies, Source}; use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace, WorkspaceCache}; use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger, InstallLogger}; @@ -48,7 +49,9 @@ use crate::commands::project::{ }; use crate::commands::{ExitStatus, diagnostics}; use crate::printer::Printer; -use crate::settings::{InstallerSettingsRef, NetworkSettings, ResolverInstallerSettings}; +use crate::settings::{ + InstallerSettingsRef, NetworkSettings, ResolverInstallerSettings, ResolverSettings, +}; /// Sync the project environment. #[allow(clippy::fn_params_excessive_bools)] @@ -223,8 +226,18 @@ pub(crate) async fn sync( } // Parse the requirements from the script. - let spec = script_specification(Pep723ItemRef::Script(script), &settings.resolver)? - .unwrap_or_default(); + let script_spec = + script_specification(Pep723ItemRef::Script(script), &settings.resolver)?; + let (spec, script_extra_build_requires) = if let Some(script_spec) = script_spec { + (script_spec.requirements, script_spec.extra_build_requires) + } else { + ( + RequirementsSpecification::default(), + uv_distribution::ExtraBuildRequires::from_lowered( + ExtraBuildDependencies::default(), + ), + ) + }; // Parse the build constraints from the script. let build_constraints = script @@ -249,6 +262,7 @@ pub(crate) async fn sync( spec, modifications, build_constraints.unwrap_or_default(), + script_extra_build_requires, &settings, &network_settings, &PlatformState::default(), @@ -495,6 +509,7 @@ fn identify_installation_target<'a>( } #[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] enum SyncTarget { /// Sync a project environment. Project(VirtualProject), @@ -579,6 +594,7 @@ pub(super) async fn do_sync( config_settings_package, no_build_isolation, no_build_isolation_package, + extra_build_dependencies, exclude_newer, link_mode, compile_bytecode, @@ -588,6 +604,57 @@ pub(super) async fn do_sync( build_dependency_strategy, } = settings; + if !preview.is_enabled(PreviewFeatures::EXTRA_BUILD_DEPENDENCIES) + && !extra_build_dependencies.is_empty() + { + warn_user_once!( + "The `extra-build-dependencies` option is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::EXTRA_BUILD_DEPENDENCIES + ); + } + + // Lower the extra build dependencies with source resolution + let extra_build_requires = match &target { + InstallTarget::Workspace { workspace, .. } + | InstallTarget::Project { workspace, .. } + | InstallTarget::NonProjectWorkspace { workspace, .. } => { + uv_distribution::ExtraBuildRequires::from_workspace( + extra_build_dependencies.clone(), + workspace, + index_locations, + sources, + )? + } + InstallTarget::Script { script, .. } => { + // Try to get extra build dependencies from the script metadata + let resolver_settings = ResolverSettings { + build_options: build_options.clone(), + config_setting: config_setting.clone(), + config_settings_package: config_settings_package.clone(), + dependency_metadata: dependency_metadata.clone(), + exclude_newer, + fork_strategy: ForkStrategy::default(), + index_locations: index_locations.clone(), + index_strategy, + keyring_provider, + link_mode, + no_build_isolation, + no_build_isolation_package: no_build_isolation_package.to_vec(), + extra_build_dependencies: extra_build_dependencies.clone(), + prerelease: PrereleaseMode::default(), + resolution: ResolutionMode::default(), + sources, + upgrade: Upgrade::default(), + build_dependency_strategy: *build_dependency_strategy, + }; + script_specification(Pep723ItemRef::Script(script), &resolver_settings)? + .map(|spec| spec.extra_build_requires) + .unwrap_or_else(|| uv_distribution::ExtraBuildRequires { + extra_build_dependencies: ExtraBuildDependencies::default(), + }) + } + }; + let client_builder = BaseClientBuilder::new() .retries_from_env()? .connectivity(network_settings.connectivity) @@ -739,6 +806,7 @@ pub(super) async fn do_sync( config_setting, config_settings_package, build_isolation, + &extra_build_requires, link_mode, build_options, &build_hasher, diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 521bf188e..01df3ac0c 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -203,6 +203,7 @@ pub(crate) async fn tree( config_settings_package: _, no_build_isolation: _, no_build_isolation_package: _, + extra_build_dependencies: _, exclude_newer: _, link_mode: _, upgrade: _, diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 192597e93..6528f61d2 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -23,7 +23,7 @@ use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_settings::{PythonInstallMirrors, ResolverInstallerOptions, ToolOptions}; use uv_tool::InstalledTools; use uv_warnings::warn_user; -use uv_workspace::WorkspaceCache; +use uv_workspace::{WorkspaceCache, pyproject::ExtraBuildDependencies}; use crate::commands::ExitStatus; use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger}; @@ -439,6 +439,7 @@ pub(crate) async fn install( spec, Modifications::Exact, Constraints::from_requirements(build_constraints.iter().cloned()), + uv_distribution::ExtraBuildRequires::from_lowered(ExtraBuildDependencies::default()), &settings, &network_settings, &state, diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index af42a9eef..f7bce3197 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -19,7 +19,7 @@ use uv_requirements::RequirementsSpecification; use uv_settings::{Combine, PythonInstallMirrors, ResolverInstallerOptions, ToolOptions}; use uv_tool::InstalledTools; use uv_warnings::write_error_chain; -use uv_workspace::WorkspaceCache; +use uv_workspace::{WorkspaceCache, pyproject::ExtraBuildDependencies}; use crate::commands::pip::loggers::{ DefaultInstallLogger, SummaryResolveLogger, UpgradeInstallLogger, @@ -337,6 +337,7 @@ async fn upgrade_tool( spec, Modifications::Exact, build_constraints, + uv_distribution::ExtraBuildRequires::from_lowered(ExtraBuildDependencies::default()), &settings, network_settings, &state, diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 14225bd99..6d3b34a86 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -29,6 +29,7 @@ use uv_shell::{Shell, shlex_posix, shlex_windows}; use uv_types::{AnyErrorBuild, BuildContext, BuildIsolation, BuildStack, HashStrategy}; use uv_virtualenv::OnExisting; use uv_warnings::warn_user; +use uv_workspace::pyproject::ExtraBuildDependencies; use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceError}; use crate::commands::ExitStatus; @@ -266,7 +267,8 @@ pub(crate) async fn venv( // Do not allow builds let build_options = BuildOptions::new(NoBinary::None, NoBuild::All); - + let extra_build_requires = + uv_distribution::ExtraBuildRequires::from_lowered(ExtraBuildDependencies::default()); // Prep the build context. let build_dispatch = BuildDispatch::new( &client, @@ -281,6 +283,7 @@ pub(crate) async fn venv( &config_settings, &config_settings_package, BuildIsolation::Isolated, + &extra_build_requires, link_mode, &build_options, &build_hasher, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 4a937b0db..57a8a0320 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -28,7 +28,7 @@ use uv_cli::{ ProjectCommand, PythonCommand, PythonNamespace, SelfCommand, SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs, compat::CompatArgs, }; -use uv_configuration::min_stack_size; +use uv_configuration::{PreviewFeatures, min_stack_size}; use uv_fs::{CWD, Simplified}; #[cfg(feature = "self-update")] use uv_pep440::release_specifiers_to_ranges; @@ -443,6 +443,16 @@ async fn run(mut cli: Cli) -> Result { // Resolve the settings from the command-line arguments and workspace configuration. let args = PipCompileSettings::resolve(args, filesystem); show_settings!(args); + if !args.settings.extra_build_dependencies.is_empty() + && !globals + .preview + .is_enabled(PreviewFeatures::EXTRA_BUILD_DEPENDENCIES) + { + warn_user_once!( + "The `extra-build-dependencies` setting is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::EXTRA_BUILD_DEPENDENCIES + ); + } // Initialize the cache. let cache = cache.init()?.with_refresh( @@ -516,6 +526,7 @@ async fn run(mut cli: Cli) -> Result { args.settings.config_settings_package, args.settings.no_build_isolation, args.settings.no_build_isolation_package, + &args.settings.extra_build_dependencies, args.settings.build_options, args.settings.python_version, args.settings.python_platform, @@ -543,6 +554,16 @@ async fn run(mut cli: Cli) -> Result { // Resolve the settings from the command-line arguments and workspace configuration. let args = PipSyncSettings::resolve(args, filesystem); show_settings!(args); + if !args.settings.extra_build_dependencies.is_empty() + && !globals + .preview + .is_enabled(PreviewFeatures::EXTRA_BUILD_DEPENDENCIES) + { + warn_user_once!( + "The `extra-build-dependencies` setting is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::EXTRA_BUILD_DEPENDENCIES + ); + } // Initialize the cache. let cache = cache.init()?.with_refresh( @@ -593,6 +614,7 @@ async fn run(mut cli: Cli) -> Result { &args.settings.config_settings_package, args.settings.no_build_isolation, args.settings.no_build_isolation_package, + &args.settings.extra_build_dependencies, args.settings.build_options, args.settings.python_version, args.settings.python_platform, @@ -621,6 +643,16 @@ async fn run(mut cli: Cli) -> Result { // Resolve the settings from the command-line arguments and workspace configuration. let mut args = PipInstallSettings::resolve(args, filesystem); show_settings!(args); + if !args.settings.extra_build_dependencies.is_empty() + && !globals + .preview + .is_enabled(PreviewFeatures::EXTRA_BUILD_DEPENDENCIES) + { + warn_user_once!( + "The `extra-build-dependencies` setting is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::EXTRA_BUILD_DEPENDENCIES + ); + } let mut requirements = Vec::with_capacity( args.package.len() + args.editables.len() + args.requirements.len(), @@ -735,6 +767,7 @@ async fn run(mut cli: Cli) -> Result { &args.settings.config_settings_package, args.settings.no_build_isolation, args.settings.no_build_isolation_package, + &args.settings.extra_build_dependencies, args.settings.build_options, args.modifications, args.settings.python_version, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 8036f3c28..4d17e2982 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -44,7 +44,7 @@ use uv_settings::{ use uv_static::EnvVars; use uv_torch::TorchMode; use uv_warnings::warn_user_once; -use uv_workspace::pyproject::DependencyType; +use uv_workspace::pyproject::{DependencyType, ExtraBuildDependencies}; use uv_workspace::pyproject_mut::AddBoundsKind; use crate::commands::ToolRunCommand; @@ -2708,6 +2708,7 @@ pub(crate) struct InstallerSettingsRef<'a> { pub(crate) config_settings_package: &'a PackageConfigSettings, pub(crate) no_build_isolation: bool, pub(crate) no_build_isolation_package: &'a [PackageName], + pub(crate) extra_build_dependencies: &'a ExtraBuildDependencies, pub(crate) exclude_newer: Option, pub(crate) link_mode: LinkMode, pub(crate) compile_bytecode: bool, @@ -2735,6 +2736,7 @@ pub(crate) struct ResolverSettings { pub(crate) link_mode: LinkMode, pub(crate) no_build_isolation: bool, pub(crate) no_build_isolation_package: Vec, + pub(crate) extra_build_dependencies: ExtraBuildDependencies, pub(crate) prerelease: PrereleaseMode, pub(crate) resolution: ResolutionMode, pub(crate) sources: SourceStrategy, @@ -2788,6 +2790,7 @@ impl From for ResolverSettings { config_settings_package: value.config_settings_package.unwrap_or_default(), no_build_isolation: value.no_build_isolation.unwrap_or_default(), no_build_isolation_package: value.no_build_isolation_package.unwrap_or_default(), + extra_build_dependencies: value.extra_build_dependencies.unwrap_or_default(), exclude_newer: value.exclude_newer, link_mode: value.link_mode.unwrap_or_default(), sources: SourceStrategy::from_args(value.no_sources.unwrap_or_default()), @@ -2878,6 +2881,7 @@ impl From for ResolverInstallerSettings { link_mode: value.link_mode.unwrap_or_default(), no_build_isolation: value.no_build_isolation.unwrap_or_default(), no_build_isolation_package: value.no_build_isolation_package.unwrap_or_default(), + extra_build_dependencies: value.extra_build_dependencies.unwrap_or_default(), prerelease: value.prerelease.unwrap_or_default(), resolution: value.resolution.unwrap_or_default(), sources: SourceStrategy::from_args(value.no_sources.unwrap_or_default()), @@ -2921,6 +2925,7 @@ pub(crate) struct PipSettings { pub(crate) torch_backend: Option, pub(crate) no_build_isolation: bool, pub(crate) no_build_isolation_package: Vec, + pub(crate) extra_build_dependencies: ExtraBuildDependencies, pub(crate) build_options: BuildOptions, pub(crate) allow_empty_requirements: bool, pub(crate) strict: bool, @@ -2988,6 +2993,7 @@ impl PipSettings { only_binary, no_build_isolation, no_build_isolation_package, + extra_build_dependencies, strict, extra, all_extras, @@ -3046,6 +3052,7 @@ impl PipSettings { config_settings_package: top_level_config_settings_package, no_build_isolation: top_level_no_build_isolation, no_build_isolation_package: top_level_no_build_isolation_package, + extra_build_dependencies: top_level_extra_build_dependencies, exclude_newer: top_level_exclude_newer, link_mode: top_level_link_mode, compile_bytecode: top_level_compile_bytecode, @@ -3082,6 +3089,8 @@ impl PipSettings { let no_build_isolation = no_build_isolation.combine(top_level_no_build_isolation); let no_build_isolation_package = no_build_isolation_package.combine(top_level_no_build_isolation_package); + let extra_build_dependencies = + extra_build_dependencies.combine(top_level_extra_build_dependencies); let exclude_newer = exclude_newer.combine(top_level_exclude_newer); let link_mode = link_mode.combine(top_level_link_mode); let compile_bytecode = compile_bytecode.combine(top_level_compile_bytecode); @@ -3177,6 +3186,10 @@ impl PipSettings { .no_build_isolation_package .combine(no_build_isolation_package) .unwrap_or_default(), + extra_build_dependencies: args + .extra_build_dependencies + .combine(extra_build_dependencies) + .unwrap_or_default(), config_setting: args .config_settings .combine(config_settings) @@ -3281,6 +3294,7 @@ impl<'a> From<&'a ResolverInstallerSettings> for InstallerSettingsRef<'a> { config_settings_package: &settings.resolver.config_settings_package, no_build_isolation: settings.resolver.no_build_isolation, no_build_isolation_package: &settings.resolver.no_build_isolation_package, + extra_build_dependencies: &settings.resolver.extra_build_dependencies, exclude_newer: settings.resolver.exclude_newer, link_mode: settings.resolver.link_mode, compile_bytecode: settings.compile_bytecode, diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 9ee284f3d..fc8dbf344 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -3920,16 +3920,17 @@ fn config_settings_registry() { .arg("iniconfig") .arg("--no-binary") .arg("iniconfig") - .arg("-C=global-option=build_ext"), @r###" + .arg("-C=global-option=build_ext"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 1 package in [TIME] + Prepared 1 package in [TIME] Installed 1 package in [TIME] + iniconfig==2.0.0 - "### + " ); // Uninstall the package. diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 65555ba56..4c417e420 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -184,6 +184,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -373,6 +374,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -563,6 +565,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -785,6 +788,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -942,6 +946,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -1143,6 +1148,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -1392,6 +1398,7 @@ fn resolve_index_url() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -1651,6 +1658,7 @@ fn resolve_index_url() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -1865,6 +1873,7 @@ fn resolve_find_links() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -2044,6 +2053,7 @@ fn resolve_top_level() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -2283,6 +2293,7 @@ fn resolve_top_level() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -2505,6 +2516,7 @@ fn resolve_top_level() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -2683,6 +2695,7 @@ fn resolve_user_configuration() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -2845,6 +2858,7 @@ fn resolve_user_configuration() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -3007,6 +3021,7 @@ fn resolve_user_configuration() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -3171,6 +3186,7 @@ fn resolve_user_configuration() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -3327,6 +3343,7 @@ fn resolve_tool() -> anyhow::Result<()> { config_settings_package: None, no_build_isolation: None, no_build_isolation_package: None, + extra_build_dependencies: None, exclude_newer: None, link_mode: Some( Clone, @@ -3369,6 +3386,7 @@ fn resolve_tool() -> anyhow::Result<()> { link_mode: Clone, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, prerelease: IfNecessaryOrExplicit, resolution: LowestDirect, sources: Enabled, @@ -3527,6 +3545,7 @@ fn resolve_poetry_toml() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -3757,6 +3776,7 @@ fn resolve_both() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -3991,6 +4011,7 @@ fn resolve_both_special_fields() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -4304,6 +4325,7 @@ fn resolve_config_file() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -4384,7 +4406,7 @@ fn resolve_config_file() -> anyhow::Result<()> { | 1 | [project] | ^^^^^^^ - unknown field `project`, expected one of `required-version`, `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `fork-strategy`, `dependency-metadata`, `config-settings`, `config-settings-package`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `python-install-mirror`, `pypy-install-mirror`, `python-downloads-json-url`, `publish-url`, `trusted-publishing`, `check-url`, `add-bounds`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `build-constraint-dependencies`, `environments`, `required-environments`, `conflicts`, `workspace`, `sources`, `managed`, `package`, `default-groups`, `dependency-groups`, `dev-dependencies`, `build-backend` + unknown field `project`, expected one of `required-version`, `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `fork-strategy`, `dependency-metadata`, `config-settings`, `config-settings-package`, `no-build-isolation`, `no-build-isolation-package`, `extra-build-dependencies`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `python-install-mirror`, `pypy-install-mirror`, `python-downloads-json-url`, `publish-url`, `trusted-publishing`, `check-url`, `add-bounds`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `build-constraint-dependencies`, `environments`, `required-environments`, `conflicts`, `workspace`, `sources`, `managed`, `package`, `default-groups`, `dependency-groups`, `dev-dependencies`, `build-backend` " ); @@ -4559,6 +4581,7 @@ fn resolve_skip_empty() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -4724,6 +4747,7 @@ fn resolve_skip_empty() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -4908,6 +4932,7 @@ fn allow_insecure_host() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -5153,6 +5178,7 @@ fn index_priority() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -5377,6 +5403,7 @@ fn index_priority() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -5607,6 +5634,7 @@ fn index_priority() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -5832,6 +5860,7 @@ fn index_priority() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -6064,6 +6093,7 @@ fn index_priority() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -6289,6 +6319,7 @@ fn index_priority() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -6458,6 +6489,7 @@ fn verify_hashes() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -6613,6 +6645,7 @@ fn verify_hashes() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -6766,6 +6799,7 @@ fn verify_hashes() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -6921,6 +6955,7 @@ fn verify_hashes() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -7074,6 +7109,7 @@ fn verify_hashes() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -7228,6 +7264,7 @@ fn verify_hashes() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, build_options: BuildOptions { no_binary: None, no_build: None, @@ -7320,7 +7357,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | EXTRA_BUILD_DEPENDENCIES, ), }, python_preference: Managed, @@ -7386,6 +7423,7 @@ fn preview_features() { link_mode: Clone, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, prerelease: IfNecessaryOrExplicit, resolution: Highest, sources: Enabled, @@ -7488,6 +7526,7 @@ fn preview_features() { link_mode: Clone, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, prerelease: IfNecessaryOrExplicit, resolution: Highest, sources: Enabled, @@ -7524,7 +7563,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | EXTRA_BUILD_DEPENDENCIES, ), }, python_preference: Managed, @@ -7590,6 +7629,7 @@ fn preview_features() { link_mode: Clone, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, prerelease: IfNecessaryOrExplicit, resolution: Highest, sources: Enabled, @@ -7692,6 +7732,7 @@ fn preview_features() { link_mode: Clone, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, prerelease: IfNecessaryOrExplicit, resolution: Highest, sources: Enabled, @@ -7794,6 +7835,7 @@ fn preview_features() { link_mode: Clone, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, prerelease: IfNecessaryOrExplicit, resolution: Highest, sources: Enabled, @@ -7898,6 +7940,7 @@ fn preview_features() { link_mode: Clone, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: {}, prerelease: IfNecessaryOrExplicit, resolution: Highest, sources: Enabled, diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 3bade6dea..a7620df92 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -1567,6 +1567,401 @@ fn sync_build_isolation_extra() -> Result<()> { Ok(()) } +#[test] +fn sync_extra_build_dependencies() -> Result<()> { + let context = TestContext::new("3.12").with_filtered_counts(); + + // Write a test package that arbitrarily requires `anyio` at build time + let child = context.temp_dir.child("child"); + child.create_dir_all()?; + let child_pyproject_toml = child.child("pyproject.toml"); + child_pyproject_toml.write_str(indoc! {r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.9" + + [build-system] + requires = ["hatchling"] + backend-path = ["."] + build-backend = "build_backend" + "#})?; + let build_backend = child.child("build_backend.py"); + build_backend.write_str(indoc! {r#" + import sys + + from hatchling.build import * + + try: + import anyio + except ModuleNotFoundError: + print("Missing `anyio` module", file=sys.stderr) + sys.exit(1) + "#})?; + child.child("src/child/__init__.py").touch()?; + + let parent = &context.temp_dir; + let pyproject_toml = parent.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = ["child"] + + [tool.uv.sources] + child = { path = "child" } + "#})?; + + context.venv().arg("--clear").assert().success(); + // Running `uv sync` should fail due to missing build-dependencies + uv_snapshot!(context.filters(), context.sync(), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + × Failed to build `child @ file://[TEMP_DIR]/child` + ├─▶ The build backend returned an error + ╰─▶ Call to `build_backend.build_wheel` failed (exit status: 1) + + [stderr] + Missing `anyio` module + + hint: This usually indicates a problem with the package or the build environment. + help: `child` was included because `parent` (v0.1.0) depends on `child` + "); + + // Adding `extra-build-dependencies` should solve the issue + pyproject_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = ["child"] + + [tool.uv.sources] + child = { path = "child" } + + [tool.uv.extra-build-dependencies] + child = ["anyio"] + "#})?; + + context.venv().arg("--clear").assert().success(); + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: The `extra-build-dependencies` option is experimental and may change without warning. Pass `--preview-features extra-build-dependencies` to disable this warning. + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + child==0.1.0 (from file://[TEMP_DIR]/child) + "); + + // Adding `extra-build-dependencies` with the wrong name should fail the build + // (the cache is invalidated when extra build dependencies change) + pyproject_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = ["child"] + + [tool.uv.sources] + child = { path = "child" } + + [tool.uv.extra-build-dependencies] + wrong_name = ["anyio"] + "#})?; + + context.venv().arg("--clear").assert().success(); + uv_snapshot!(context.filters(), context.sync(), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + warning: The `extra-build-dependencies` option is experimental and may change without warning. Pass `--preview-features extra-build-dependencies` to disable this warning. + Resolved [N] packages in [TIME] + × Failed to build `child @ file://[TEMP_DIR]/child` + ├─▶ The build backend returned an error + ╰─▶ Call to `build_backend.build_wheel` failed (exit status: 1) + + [stderr] + Missing `anyio` module + + hint: This usually indicates a problem with the package or the build environment. + help: `child` was included because `parent` (v0.1.0) depends on `child` + "); + + // Write a test package that arbitrarily bans `anyio` at build time + let bad_child = context.temp_dir.child("bad_child"); + bad_child.create_dir_all()?; + let bad_child_pyproject_toml = bad_child.child("pyproject.toml"); + bad_child_pyproject_toml.write_str(indoc! {r#" + [project] + name = "bad_child" + version = "0.1.0" + requires-python = ">=3.9" + + [build-system] + requires = ["hatchling"] + backend-path = ["."] + build-backend = "build_backend" + "#})?; + let build_backend = bad_child.child("build_backend.py"); + build_backend.write_str(indoc! {r#" + import sys + + from hatchling.build import * + + try: + import anyio + except ModuleNotFoundError: + pass + else: + print("Found `anyio` module", file=sys.stderr) + sys.exit(1) + "#})?; + bad_child.child("src/bad_child/__init__.py").touch()?; + + // Depend on `bad_child` too + pyproject_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = ["child", "bad_child"] + + [tool.uv.sources] + child = { path = "child" } + bad_child = { path = "bad_child" } + + [tool.uv.extra-build-dependencies] + child = ["anyio"] + bad_child = ["anyio"] + "#})?; + + // Confirm that `bad_child` fails if anyio is provided + context.venv().arg("--clear").assert().success(); + uv_snapshot!(context.filters(), context.sync(), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + warning: The `extra-build-dependencies` option is experimental and may change without warning. Pass `--preview-features extra-build-dependencies` to disable this warning. + Resolved [N] packages in [TIME] + × Failed to build `bad-child @ file://[TEMP_DIR]/bad_child` + ├─▶ The build backend returned an error + ╰─▶ Call to `build_backend.build_wheel` failed (exit status: 1) + + [stderr] + Found `anyio` module + + hint: This usually indicates a problem with the package or the build environment. + help: `bad-child` was included because `parent` (v0.1.0) depends on `bad-child` + "); + + // But `anyio` is not provided to `bad_child` if scoped to `child` + pyproject_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = ["child", "bad_child"] + + [tool.uv.sources] + child = { path = "child" } + bad_child = { path = "bad_child" } + + [tool.uv.extra-build-dependencies] + child = ["anyio"] + "#})?; + + context.venv().arg("--clear").assert().success(); + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: The `extra-build-dependencies` option is experimental and may change without warning. Pass `--preview-features extra-build-dependencies` to disable this warning. + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + bad-child==0.1.0 (from file://[TEMP_DIR]/bad_child) + + child==0.1.0 (from file://[TEMP_DIR]/child) + "); + + Ok(()) +} + +#[test] +fn sync_extra_build_dependencies_sources() -> Result<()> { + let context = TestContext::new("3.12").with_filtered_counts(); + + let anyio_local = context.workspace_root.join("scripts/packages/anyio_local"); + + // Write a test package that arbitrarily requires `anyio` at a specific _path_ at build time + let child = context.temp_dir.child("child"); + child.create_dir_all()?; + let child_pyproject_toml = child.child("pyproject.toml"); + child_pyproject_toml.write_str(indoc! {r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.9" + + [build-system] + requires = ["hatchling"] + backend-path = ["."] + build-backend = "build_backend" + "#})?; + let build_backend = child.child("build_backend.py"); + build_backend.write_str(&formatdoc! {r#" + import sys + + from hatchling.build import * + + try: + import anyio + except ModuleNotFoundError: + print("Missing `anyio` module", file=sys.stderr) + sys.exit(1) + + # Check that we got the local version of anyio by checking for the marker + if not hasattr(anyio, 'LOCAL_ANYIO_MARKER'): + print("Found system anyio instead of local anyio", file=sys.stderr) + sys.exit(1) + "#})?; + child.child("src/child/__init__.py").touch()?; + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(&formatdoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["child"] + + [tool.uv.sources] + anyio = {{ path = "{anyio_local}" }} + child = {{ path = "child" }} + + [tool.uv.extra-build-dependencies] + child = ["anyio"] + "#, + anyio_local = anyio_local.portable_display(), + })?; + + // Running `uv sync` should succeed, as `anyio` is provided as a source + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: The `extra-build-dependencies` option is experimental and may change without warning. Pass `--preview-features extra-build-dependencies` to disable this warning. + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + child==0.1.0 (from file://[TEMP_DIR]/child) + "); + + // TODO(zanieb): We want to test with `--no-sources` too but unfortunately that's not easy + // because it'll disable the `child` path source too! + + Ok(()) +} + +#[test] +fn sync_extra_build_dependencies_sources_from_child() -> Result<()> { + let context = TestContext::new("3.12").with_filtered_counts(); + + let anyio_local = context.workspace_root.join("scripts/packages/anyio_local"); + + // Write a test package that arbitrarily requires `anyio` at a specific _path_ at build time + let child = context.temp_dir.child("child"); + child.create_dir_all()?; + let child_pyproject_toml = child.child("pyproject.toml"); + child_pyproject_toml.write_str(&formatdoc! {r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.9" + + [build-system] + requires = ["hatchling"] + backend-path = ["."] + build-backend = "build_backend" + + [tool.uv.sources] + anyio = {{ path = "{}" }} + "#, anyio_local.portable_display() + })?; + let build_backend = child.child("build_backend.py"); + build_backend.write_str(&formatdoc! {r#" + import sys + + from hatchling.build import * + + try: + import anyio + except ModuleNotFoundError: + print("Missing `anyio` module", file=sys.stderr) + sys.exit(1) + + # Check that we got the local version of anyio by checking for the marker + if not hasattr(anyio, 'LOCAL_ANYIO_MARKER'): + print("Found system anyio instead of local anyio", file=sys.stderr) + sys.exit(1) + "#})?; + child.child("src/child/__init__.py").touch()?; + + 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 = ["child"] + + [tool.uv.sources] + child = { path = "child" } + + [tool.uv.extra-build-dependencies] + child = ["anyio"] + "#, + })?; + + // Running `uv sync` should fail due to the unapplied source + uv_snapshot!(context.filters(), context.sync().arg("--reinstall").arg("--refresh"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + warning: The `extra-build-dependencies` option is experimental and may change without warning. Pass `--preview-features extra-build-dependencies` to disable this warning. + Resolved [N] packages in [TIME] + × Failed to build `child @ file://[TEMP_DIR]/child` + ├─▶ The build backend returned an error + ╰─▶ Call to `build_backend.build_wheel` failed (exit status: 1) + + [stderr] + Found system anyio instead of local anyio + + hint: This usually indicates a problem with the package or the build environment. + help: `child` was included because `project` (v0.1.0) depends on `child` + "); + + Ok(()) +} + /// Avoid using incompatible versions for build dependencies that are also part of the resolved /// environment. This is a very subtle issue, but: when locking, we don't enforce platform /// compatibility. So, if we reuse the resolver state to install, and the install itself has to @@ -4198,6 +4593,187 @@ fn no_install_project_no_build() -> Result<()> { Ok(()) } +#[test] +fn sync_extra_build_dependencies_script() -> Result<()> { + let context = TestContext::new("3.12").with_filtered_counts(); + + // Write a test package that arbitrarily requires `anyio` at build time + let child = context.temp_dir.child("child"); + child.create_dir_all()?; + let child_pyproject_toml = child.child("pyproject.toml"); + child_pyproject_toml.write_str(indoc! {r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.9" + [build-system] + requires = ["hatchling"] + backend-path = ["."] + build-backend = "build_backend" + "#})?; + let build_backend = child.child("build_backend.py"); + build_backend.write_str(indoc! {r#" + import sys + from hatchling.build import * + try: + import anyio + except ModuleNotFoundError: + print("Missing `anyio` module", file=sys.stderr) + sys.exit(1) + "#})?; + child.child("src/child/__init__.py").touch()?; + + // Create a script that depends on the child package + let script = context.temp_dir.child("script.py"); + script.write_str(indoc! {r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["child"] + # + # [tool.uv.sources] + # child = { path = "child" } + # /// + "#})?; + + let filters = context + .filters() + .into_iter() + .chain(vec![( + r"environments-v2/script-[a-z0-9]+", + "environments-v2/script-[HASH]", + )]) + .collect::>(); + + // Running `uv sync` should fail due to missing build-dependencies + uv_snapshot!(filters, context.sync().arg("--script").arg("script.py"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Creating script environment at: [CACHE_DIR]/environments-v2/script-[HASH] + Resolved [N] packages in [TIME] + × Failed to build `child @ file://[TEMP_DIR]/child` + ├─▶ The build backend returned an error + ╰─▶ Call to `build_backend.build_wheel` failed (exit status: 1) + + [stderr] + Missing `anyio` module + + hint: This usually indicates a problem with the package or the build environment. + "); + + // Add extra build dependencies to the script + script.write_str(indoc! {r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["child"] + # + # [tool.uv.sources] + # child = { path = "child" } + # + # [tool.uv.extra-build-dependencies] + # child = ["anyio"] + # /// + "#})?; + + // Running `uv sync` should now succeed due to extra build-dependencies + context.venv().arg("--clear").assert().success(); + uv_snapshot!(filters, context.sync().arg("--script").arg("script.py"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using script environment at: [CACHE_DIR]/environments-v2/script-[HASH] + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + child==0.1.0 (from file://[TEMP_DIR]/child) + "); + + Ok(()) +} + +#[test] +fn sync_extra_build_dependencies_script_sources() -> Result<()> { + let context = TestContext::new("3.12").with_filtered_counts(); + let anyio_local = context.workspace_root.join("scripts/packages/anyio_local"); + + // Write a test package that arbitrarily requires `anyio` at a specific _path_ at build time + let child = context.temp_dir.child("child"); + child.create_dir_all()?; + let child_pyproject_toml = child.child("pyproject.toml"); + child_pyproject_toml.write_str(indoc! {r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.9" + [build-system] + requires = ["hatchling"] + backend-path = ["."] + build-backend = "build_backend" + "#})?; + let build_backend = child.child("build_backend.py"); + build_backend.write_str(&formatdoc! {r#" + import sys + from hatchling.build import * + try: + import anyio + except ModuleNotFoundError: + print("Missing `anyio` module", file=sys.stderr) + sys.exit(1) + + # Check that we got the local version of anyio by checking for the marker + if not hasattr(anyio, 'LOCAL_ANYIO_MARKER'): + print("Found system anyio instead of local anyio", file=sys.stderr) + sys.exit(1) + "#})?; + child.child("src/child/__init__.py").touch()?; + + // Create a script that depends on the child package + let script = context.temp_dir.child("script.py"); + script.write_str(&formatdoc! {r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["child"] + # + # [tool.uv.sources] + # anyio = {{ path = "{}" }} + # child = {{ path = "child" }} + # + # [tool.uv.extra-build-dependencies] + # child = ["anyio"] + # /// + "#, anyio_local.portable_display() + })?; + + let filters = context + .filters() + .into_iter() + .chain(vec![( + r"environments-v2/script-[a-z0-9]+", + "environments-v2/script-[HASH]", + )]) + .collect::>(); + + // Running `uv sync` should succeed with the sources applied + uv_snapshot!(filters, context.sync().arg("--script").arg("script.py"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Creating script environment at: [CACHE_DIR]/environments-v2/script-[HASH] + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + child==0.1.0 (from file://[TEMP_DIR]/child) + "); + + Ok(()) +} + #[test] fn virtual_no_build() -> Result<()> { let context = TestContext::new("3.12"); diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 31dd1157b..dbf6644af 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -202,6 +202,28 @@ environments = ["sys_platform == 'darwin'"] --- +### [`extra-build-dependencies`](#extra-build-dependencies) {: #extra-build-dependencies } + +Additional build dependencies for packages. + +This allows extending the PEP 517 build environment for the project's dependencies with +additional packages. This is useful for packages that assume the presence of packages, like, +`pip`, and do not declare them as build dependencies. + +**Default value**: `[]` + +**Type**: `dict` + +**Example usage**: + +```toml title="pyproject.toml" + +[tool.uv.extra-build-dependencies] +pytest = ["pip"] +``` + +--- + ### [`index`](#index) {: #index } The indexes to use when resolving dependencies. @@ -1135,6 +1157,36 @@ behave consistently across timezones. --- +### [`extra-build-dependencies`](#extra-build-dependencies) {: #extra-build-dependencies } + +Additional build dependencies for packages. + +This allows extending the PEP 517 build environment for the project's dependencies with +additional packages. This is useful for packages that assume the presence of packages, like, +`pip`, and do not declare them as build dependencies. + +**Default value**: `[]` + +**Type**: `dict` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.uv] + [extra-build-dependencies] + pytest = ["setuptools"] + ``` +=== "uv.toml" + + ```toml + [extra-build-dependencies] + pytest = ["setuptools"] + ``` + +--- + ### [`extra-index-url`](#extra-index-url) {: #extra-index-url } Extra URLs of package indexes to use, in addition to `--index-url`. @@ -2596,6 +2648,38 @@ Only applies to `pyproject.toml`, `setup.py`, and `setup.cfg` sources. --- +#### [`extra-build-dependencies`](#pip_extra-build-dependencies) {: #pip_extra-build-dependencies } + + +Additional build dependencies for packages. + +This allows extending the PEP 517 build environment for the project's dependencies with +additional packages. This is useful for packages that assume the presence of packages, like, +`pip`, and do not declare them as build dependencies. + +**Default value**: `[]` + +**Type**: `dict` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.uv.pip] + [extra-build-dependencies] + pytest = ["setuptools"] + ``` +=== "uv.toml" + + ```toml + [pip] + [extra-build-dependencies] + pytest = ["setuptools"] + ``` + +--- + #### [`extra-index-url`](#pip_extra-index-url) {: #pip_extra-index-url } diff --git a/scripts/packages/anyio_local/anyio/__init__.py b/scripts/packages/anyio_local/anyio/__init__.py index e69de29bb..a3e374663 100644 --- a/scripts/packages/anyio_local/anyio/__init__.py +++ b/scripts/packages/anyio_local/anyio/__init__.py @@ -0,0 +1,2 @@ +# This is a local dummy anyio package +LOCAL_ANYIO_MARKER = True \ No newline at end of file diff --git a/uv.schema.json b/uv.schema.json index d592eca73..81daff6f4 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -225,6 +225,17 @@ } ] }, + "extra-build-dependencies": { + "description": "Additional build dependencies for packages.\n\nThis allows extending the PEP 517 build environment for the project's dependencies with\nadditional packages. This is useful for packages that assume the presence of packages, like,\n`pip`, and do not declare them as build dependencies.", + "anyOf": [ + { + "$ref": "#/definitions/ToolUvExtraBuildDependencies" + }, + { + "type": "null" + } + ] + }, "extra-index-url": { "description": "Extra URLs of package indexes to use, in addition to `--index-url`.\n\nAccepts either a repository compliant with [PEP 503](https://peps.python.org/pep-0503/)\n(the simple repository API), or a local directory laid out in the same format.\n\nAll indexes provided via this flag take priority over the index specified by\n[`index_url`](#index-url) or [`index`](#index) with `default = true`. When multiple indexes\nare provided, earlier values take priority.\n\nTo control uv's resolution strategy when multiple indexes are present, see\n[`index_strategy`](#index-strategy).\n\n(Deprecated: use `index` instead.)", "type": [ @@ -1312,6 +1323,19 @@ "$ref": "#/definitions/ExtraName" } }, + "extra-build-dependencies": { + "description": "Additional build dependencies for packages.\n\nThis allows extending the PEP 517 build environment for the project's dependencies with\nadditional packages. This is useful for packages that assume the presence of packages, like,\n`pip`, and do not declare them as build dependencies.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/Requirement" + } + } + }, "extra-index-url": { "description": "Extra URLs of package indexes to use, in addition to `--index-url`.\n\nAccepts either a repository compliant with [PEP 503](https://peps.python.org/pep-0503/)\n(the simple repository API), or a local directory laid out in the same format.\n\nAll indexes provided via this flag take priority over the index specified by\n[`index_url`](#index-url). When multiple indexes are provided, earlier values take priority.\n\nTo control uv's resolution strategy when multiple indexes are present, see\n[`index_strategy`](#index-strategy).", "type": [ @@ -2341,6 +2365,15 @@ "$ref": "#/definitions/DependencyGroupSettings" } }, + "ToolUvExtraBuildDependencies": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/Requirement" + } + } + }, "ToolUvSources": { "type": "object", "additionalProperties": {