diff --git a/Cargo.lock b/Cargo.lock index 5b2fd66b8..396b77bea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5516,6 +5516,7 @@ dependencies = [ "tracing", "unicode-width 0.2.1", "unscanny", + "uv-cache-key", "version-ranges", ] @@ -5539,6 +5540,7 @@ dependencies = [ "tracing-test", "unicode-width 0.2.1", "url", + "uv-cache-key", "uv-fs", "uv-normalize", "uv-pep440", @@ -5864,6 +5866,8 @@ dependencies = [ "thiserror 2.0.12", "toml", "url", + "uv-configuration", + "uv-distribution-types", "uv-pep440", "uv-pep508", "uv-pypi-types", diff --git a/crates/uv-bench/benches/uv.rs b/crates/uv-bench/benches/uv.rs index 8adfd5a0e..df19354f6 100644 --- a/crates/uv-bench/benches/uv.rs +++ b/crates/uv-bench/benches/uv.rs @@ -141,6 +141,7 @@ mod resolver { universal: bool, ) -> Result { let build_isolation = BuildIsolation::default(); + let extra_build_requires = uv_distribution::ExtraBuildRequires::default(); let build_options = BuildOptions::default(); let concurrency = Concurrency::default(); let config_settings = ConfigSettings::default(); @@ -189,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..58cf441ab 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: Vec = 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() @@ -344,11 +354,18 @@ impl SourceBuild { if build_isolation.is_isolated(package_name.as_ref()) { debug!("Resolving build requirements"); + let dependency_sources = if extra_build_dependencies.is_empty() { + "`build-system.requires`" + } else { + "`build-system.requires` and `extra-build-dependencies`" + }; + let resolved_requirements = Self::get_resolved_requirements( build_context, source_build_context, &default_backend, &pep517_backend, + extra_build_dependencies, build_stack, ) .await?; @@ -356,7 +373,7 @@ impl SourceBuild { build_context .install(&resolved_requirements, &venv, build_stack) .await - .map_err(|err| Error::RequirementsInstall("`build-system.requires`", err.into()))?; + .map_err(|err| Error::RequirementsInstall(dependency_sources, err.into()))?; } else { debug!("Proceeding without build isolation"); } @@ -471,10 +488,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,12 +509,25 @@ impl SourceBuild { resolved_requirements } } else { + let (requirements, dependency_sources) = if extra_build_dependencies.is_empty() { + ( + Cow::Borrowed(&pep517_backend.requirements), + "`build-system.requires`", + ) + } 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), + "`build-system.requires` and `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()) - })? + .map_err(|err| Error::RequirementsResolve(dependency_sources, err.into()))? }, ) } @@ -604,6 +637,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 c5f94de1b..54c2d80ca 100644 --- a/crates/uv-cli/src/options.rs +++ b/crates/uv-cli/src/options.rs @@ -354,6 +354,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: ExcludeNewer::from_args( exclude_newer, exclude_newer_package.unwrap_or_default(), @@ -475,6 +476,7 @@ pub fn resolver_installer_options( } else { Some(no_build_isolation_package) }, + extra_build_dependencies: None, exclude_newer, exclude_newer_package: exclude_newer_package.map(ExcludeNewerPackage::from_iter), link_mode, diff --git a/crates/uv-configuration/src/preview.rs b/crates/uv-configuration/src/preview.rs index c8d67be5b..fab7dd34e 100644 --- a/crates/uv-configuration/src/preview.rs +++ b/crates/uv-configuration/src/preview.rs @@ -14,6 +14,7 @@ bitflags::bitflags! { const JSON_OUTPUT = 1 << 2; const PYLOCK = 1 << 3; const ADD_BOUNDS = 1 << 4; + const EXTRA_BUILD_DEPENDENCIES = 1 << 5; } } @@ -28,6 +29,7 @@ impl PreviewFeatures { Self::JSON_OUTPUT => "json-output", Self::PYLOCK => "pylock", Self::ADD_BOUNDS => "add-bounds", + Self::EXTRA_BUILD_DEPENDENCIES => "extra-build-dependencies", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -70,6 +72,7 @@ impl FromStr for PreviewFeatures { "json-output" => Self::JSON_OUTPUT, "pylock" => Self::PYLOCK, "add-bounds" => Self::ADD_BOUNDS, + "extra-build-dependencies" => Self::EXTRA_BUILD_DEPENDENCIES, _ => { warn_user_once!("Unknown preview feature: `{part}`"); continue; @@ -232,6 +235,10 @@ mod tests { assert_eq!(PreviewFeatures::JSON_OUTPUT.flag_as_str(), "json-output"); assert_eq!(PreviewFeatures::PYLOCK.flag_as_str(), "pylock"); assert_eq!(PreviewFeatures::ADD_BOUNDS.flag_as_str(), "add-bounds"); + 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 5d0adb47b..ddc2d5ed5 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, @@ -116,6 +118,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, @@ -138,6 +141,7 @@ impl<'a> BuildDispatch<'a> { config_settings, config_settings_package, build_isolation, + extra_build_requires, link_mode, build_options, hasher, @@ -219,6 +223,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], @@ -452,6 +460,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..9aa14bd9e 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, Default)] +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/Cargo.toml b/crates/uv-pep440/Cargo.toml index 128db08ef..278077a2b 100644 --- a/crates/uv-pep440/Cargo.toml +++ b/crates/uv-pep440/Cargo.toml @@ -20,6 +20,7 @@ serde = { workspace = true, features = ["derive"] } tracing = { workspace = true, optional = true } unicode-width = { workspace = true } unscanny = { workspace = true } +uv-cache-key = { workspace = true } # Adds conversions from [`VersionSpecifiers`] to [`version_ranges::Ranges`] version-ranges = { workspace = true, optional = true } diff --git a/crates/uv-pep440/src/version.rs b/crates/uv-pep440/src/version.rs index 223701692..31b5a8e35 100644 --- a/crates/uv-pep440/src/version.rs +++ b/crates/uv-pep440/src/version.rs @@ -10,6 +10,7 @@ use std::{ str::FromStr, sync::Arc, }; +use uv_cache_key::{CacheKey, CacheKeyHasher}; /// One of `~=` `==` `!=` `<=` `>=` `<` `>` `===` #[derive(Eq, Ord, PartialEq, PartialOrd, Debug, Hash, Clone, Copy)] @@ -114,6 +115,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 +169,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}") } } @@ -930,6 +935,46 @@ impl Hash for Version { } } +impl CacheKey for Version { + fn cache_key(&self, state: &mut CacheKeyHasher) { + self.epoch().cache_key(state); + + let release = self.release(); + release.len().cache_key(state); + for segment in release.iter() { + segment.cache_key(state); + } + + if let Some(pre) = self.pre() { + 1u8.cache_key(state); + match pre.kind { + PrereleaseKind::Alpha => 0u8.cache_key(state), + PrereleaseKind::Beta => 1u8.cache_key(state), + PrereleaseKind::Rc => 2u8.cache_key(state), + } + pre.number.cache_key(state); + } else { + 0u8.cache_key(state); + } + + if let Some(post) = self.post() { + 1u8.cache_key(state); + post.cache_key(state); + } else { + 0u8.cache_key(state); + } + + if let Some(dev) = self.dev() { + 1u8.cache_key(state); + dev.cache_key(state); + } else { + 0u8.cache_key(state); + } + + self.local().cache_key(state); + } +} + impl PartialOrd for Version { #[inline] fn partial_cmp(&self, other: &Self) -> Option { @@ -1711,6 +1756,23 @@ impl std::fmt::Display for LocalVersionSlice<'_> { } } +impl CacheKey for LocalVersionSlice<'_> { + fn cache_key(&self, state: &mut CacheKeyHasher) { + match self { + LocalVersionSlice::Segments(segments) => { + 0u8.cache_key(state); + segments.len().cache_key(state); + for segment in *segments { + segment.cache_key(state); + } + } + LocalVersionSlice::Max => { + 1u8.cache_key(state); + } + } + } +} + impl PartialOrd for LocalVersionSlice<'_> { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) @@ -1777,6 +1839,21 @@ impl std::fmt::Display for LocalSegment { } } +impl CacheKey for LocalSegment { + fn cache_key(&self, state: &mut CacheKeyHasher) { + match self { + Self::String(string) => { + 0u8.cache_key(state); + string.cache_key(state); + } + Self::Number(number) => { + 1u8.cache_key(state); + number.cache_key(state); + } + } + } +} + impl PartialOrd for LocalSegment { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) 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..dd516f570 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().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/Cargo.toml b/crates/uv-scripts/Cargo.toml index 124eb1fea..4cff3f5bc 100644 --- a/crates/uv-scripts/Cargo.toml +++ b/crates/uv-scripts/Cargo.toml @@ -11,6 +11,8 @@ doctest = false workspace = true [dependencies] +uv-configuration = { workspace = true } +uv-distribution-types = { workspace = true } uv-pep440 = { workspace = true } uv-pep508 = { workspace = true } uv-pypi-types = { workspace = true } diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index b80cdc219..474b1f91b 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -9,6 +9,7 @@ use serde::Deserialize; use thiserror::Error; use url::Url; +use uv_configuration::SourceStrategy; use uv_pep440::VersionSpecifiers; use uv_pep508::PackageName; use uv_pypi_types::VerbatimParsedUrl; @@ -96,6 +97,46 @@ impl Pep723ItemRef<'_> { Self::Remote(..) => None, } } + + /// Determine the working directory for the script. + pub fn directory(&self) -> Result { + match self { + Self::Script(script) => Ok(std::path::absolute(&script.path)? + .parent() + .expect("script path has no parent") + .to_owned()), + Self::Stdin(..) | Self::Remote(..) => std::env::current_dir(), + } + } + + /// Collect any `tool.uv.index` from the script. + pub fn indexes(&self, source_strategy: SourceStrategy) -> &[uv_distribution_types::Index] { + match source_strategy { + SourceStrategy::Enabled => self + .metadata() + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.top_level.index.as_deref()) + .unwrap_or(&[]), + SourceStrategy::Disabled => &[], + } + } + + /// Collect any `tool.uv.sources` from the script. + pub fn sources(&self, source_strategy: SourceStrategy) -> &BTreeMap { + static EMPTY: BTreeMap = BTreeMap::new(); + match source_strategy { + SourceStrategy::Enabled => self + .metadata() + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.sources.as_ref()) + .unwrap_or(&EMPTY), + SourceStrategy::Disabled => &EMPTY, + } + } } impl<'item> From<&'item Pep723Item> for Pep723ItemRef<'item> { @@ -108,6 +149,12 @@ impl<'item> From<&'item Pep723Item> for Pep723ItemRef<'item> { } } +impl<'item> From<&'item Pep723Script> for Pep723ItemRef<'item> { + fn from(script: &'item Pep723Script) -> Self { + Self::Script(script) + } +} + /// A PEP 723 script, including its [`Pep723Metadata`]. #[derive(Debug, Clone)] pub struct Pep723Script { @@ -381,6 +428,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 a2d78e7b1..084846948 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; @@ -17,6 +17,7 @@ use uv_resolver::{ PrereleaseMode, ResolutionMode, }; use uv_torch::TorchMode; +use uv_workspace::pyproject::ExtraBuildDependencies; use uv_workspace::pyproject_mut::AddBoundsKind; use crate::{FilesystemOptions, Options, PipOptions}; @@ -124,6 +125,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 [`ExcludeNewerPackage`] instances by merging them, with the values in `self` taking precedence. fn combine(self, other: Option) -> Option { @@ -192,3 +208,30 @@ impl Combine for ExcludeNewer { self } } + +impl Combine for ExtraBuildDependencies { + fn combine(mut self, other: Self) -> Self { + for (key, value) in other { + match self.entry(key) { + std::collections::btree_map::Entry::Occupied(mut entry) => { + // Combine the vecs, with self taking precedence + let existing = entry.get_mut(); + existing.extend(value); + } + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(value); + } + } + } + self + } +} + +impl Combine for Option { + fn combine(self, other: Option) -> Option { + match (self, other) { + (Some(a), Some(b)) => Some(a.combine(b)), + (a, b) => a.or(b), + } + } +} diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index f8385d006..4ca8a5af8 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, exclude_newer_package, link_mode, @@ -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 6062d5d0e..8e50d6999 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -24,7 +24,7 @@ use uv_resolver::{ }; 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)] @@ -376,6 +376,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, } @@ -628,6 +629,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., @@ -1135,6 +1150,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( @@ -1719,6 +1748,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, } } @@ -1784,6 +1814,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 exclude_newer_package: Option, pub link_mode: Option, @@ -1813,6 +1844,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, exclude_newer_package: value.exclude_newer_package, link_mode: value.link_mode, @@ -1844,6 +1876,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, exclude_newer_package: value.exclude_newer_package, link_mode: value.link_mode, @@ -1898,6 +1931,7 @@ pub struct OptionsWire { config_settings_package: Option, no_build_isolation: Option, no_build_isolation_package: Option>, + extra_build_dependencies: Option, exclude_newer: Option, exclude_newer_package: Option, link_mode: Option, @@ -2017,6 +2051,7 @@ impl From for Options { sources, default_groups, dependency_groups, + extra_build_dependencies, dev_dependencies, managed, package, @@ -2057,6 +2092,7 @@ impl From for Options { config_settings_package, no_build_isolation, no_build_isolation_package, + extra_build_dependencies, exclude_newer, exclude_newer_package, link_mode, 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..5b933a130 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -50,6 +50,55 @@ pub enum PyprojectTomlError { MissingVersion, } +/// Helper function to deserialize a map while ensuring all keys are unique. +fn deserialize_unique_map<'de, D, K, V, F>( + deserializer: D, + error_msg: F, +) -> Result, D::Error> +where + D: Deserializer<'de>, + K: Deserialize<'de> + Ord + std::fmt::Display, + V: Deserialize<'de>, + F: FnOnce(&K) -> String, +{ + struct Visitor(F, std::marker::PhantomData<(K, V)>); + + impl<'de, K, V, F> serde::de::Visitor<'de> for Visitor + where + K: Deserialize<'de> + Ord + std::fmt::Display, + V: Deserialize<'de>, + F: FnOnce(&K) -> String, + { + type Value = BTreeMap; + + 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>, + { + use std::collections::btree_map::Entry; + + let mut map = BTreeMap::new(); + while let Some((key, value)) = access.next_entry::()? { + match map.entry(key) { + Entry::Occupied(entry) => { + return Err(serde::de::Error::custom((self.0)(entry.key()))); + } + Entry::Vacant(entry) => { + entry.insert(value); + } + } + } + Ok(map) + } + } + + deserializer.deserialize_map(Visitor(error_msg, std::marker::PhantomData)) +} + /// A `pyproject.toml` as specified in PEP 517. #[derive(Deserialize, Debug, Clone)] #[cfg_attr(test, derive(Serialize))] @@ -378,6 +427,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 @@ -643,38 +707,10 @@ impl<'de> serde::de::Deserialize<'de> for ToolUvSources { where D: Deserializer<'de>, { - struct SourcesVisitor; - - impl<'de> serde::de::Visitor<'de> for SourcesVisitor { - type Value = ToolUvSources; - - 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 sources = BTreeMap::new(); - while let Some((key, value)) = access.next_entry::()? { - match sources.entry(key) { - std::collections::btree_map::Entry::Occupied(entry) => { - return Err(serde::de::Error::custom(format!( - "duplicate sources for package `{}`", - entry.key() - ))); - } - std::collections::btree_map::Entry::Vacant(entry) => { - entry.insert(value); - } - } - } - Ok(ToolUvSources(sources)) - } - } - - deserializer.deserialize_map(SourcesVisitor) + deserialize_unique_map(deserializer, |key: &PackageName| { + format!("duplicate sources for package `{key}`") + }) + .map(ToolUvSources) } } @@ -702,40 +738,10 @@ impl<'de> serde::de::Deserialize<'de> for ToolUvDependencyGroups { where D: Deserializer<'de>, { - struct SourcesVisitor; - - impl<'de> serde::de::Visitor<'de> for SourcesVisitor { - type Value = ToolUvDependencyGroups; - - 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 settings for dependency group `{}`", - entry.key() - ))); - } - std::collections::btree_map::Entry::Vacant(entry) => { - entry.insert(value); - } - } - } - Ok(ToolUvDependencyGroups(groups)) - } - } - - deserializer.deserialize_map(SourcesVisitor) + deserialize_unique_map(deserializer, |key: &GroupName| { + format!("duplicate settings for dependency group `{key}`") + }) + .map(ToolUvDependencyGroups) } } @@ -749,6 +755,51 @@ pub struct DependencyGroupSettings { pub requires_python: Option, } +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ExtraBuildDependencies( + BTreeMap>>, +); + +impl std::ops::Deref for ExtraBuildDependencies { + type Target = BTreeMap>>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for ExtraBuildDependencies { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl IntoIterator for ExtraBuildDependencies { + type Item = (PackageName, Vec>); + type IntoIter = std::collections::btree_map::IntoIter< + PackageName, + Vec>, + >; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +/// Ensure that all keys in the TOML table are unique. +impl<'de> serde::de::Deserialize<'de> for ExtraBuildDependencies { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_unique_map(deserializer, |key: &PackageName| { + format!("duplicate extra-build-dependencies for `{key}`") + }) + .map(ExtraBuildDependencies) + } +} + #[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 78e27e975..7ec57e1d6 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}; 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: _, @@ -346,6 +348,7 @@ async fn build_impl( build_constraints, *no_build_isolation, no_build_isolation_package, + extra_build_dependencies, *index_strategy, *keyring_provider, exclude_newer.clone(), @@ -424,6 +427,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: ExcludeNewer, @@ -560,6 +564,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, @@ -573,6 +579,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 dba91e106..26ae9f11c 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 39092d03f..8e0276bc9 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 9e8943d64..90bffc2aa 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 45727b53e..dbbc5a77b 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -37,7 +37,7 @@ use uv_python::{Interpreter, PythonDownloads, PythonEnvironment, PythonPreferenc use uv_redacted::DisplaySafeUrl; use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification}; use uv_resolver::FlatIndex; -use uv_scripts::{Pep723ItemRef, Pep723Metadata, Pep723Script}; +use uv_scripts::{Pep723Metadata, Pep723Script}; use uv_settings::PythonInstallMirrors; use uv_types::{BuildIsolation, HashStrategy}; use uv_warnings::warn_user_once; @@ -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(_) => { @@ -212,7 +221,7 @@ pub(crate) async fn add( // Discover the interpreter. let interpreter = ScriptInterpreter::discover( - Pep723ItemRef::Script(&script), + (&script).into(), python.as_deref().map(PythonRequest::parse), &network_settings, python_preference, @@ -428,6 +437,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, @@ -441,6 +462,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, diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index df839be08..2f1b733d6 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -15,7 +15,7 @@ use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; use uv_requirements::is_pylock_toml; use uv_resolver::{PylockToml, RequirementsTxtExport}; -use uv_scripts::{Pep723ItemRef, Pep723Script}; +use uv_scripts::Pep723Script; use uv_settings::PythonInstallMirrors; use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace, WorkspaceCache}; @@ -132,7 +132,7 @@ pub(crate) async fn export( } else { Some(match &target { ExportTarget::Script(script) => ScriptInterpreter::discover( - Pep723ItemRef::Script(script), + script.into(), python.as_deref().map(PythonRequest::parse), &network_settings, python_preference, diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 8edcaff71..ad45d126b 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -13,7 +13,7 @@ use uv_cache::Cache; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ Concurrency, Constraints, DependencyGroupsWithDefaults, DryRun, ExtrasSpecification, Preview, - Reinstall, Upgrade, + PreviewFeatures, Reinstall, Upgrade, }; use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; @@ -32,7 +32,7 @@ use uv_resolver::{ FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement, ResolverEnvironment, ResolverManifest, SatisfiesResult, UniversalMarker, }; -use uv_scripts::{Pep723ItemRef, Pep723Script}; +use uv_scripts::Pep723Script; use uv_settings::PythonInstallMirrors; use uv_types::{BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_warnings::{warn_user, warn_user_once}; @@ -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_extra_build_requires, }; use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter}; use crate::commands::{ExitStatus, ScriptPath, diagnostics, pip}; @@ -162,7 +162,7 @@ pub(crate) async fn lock( .await? .into_interpreter(), LockTarget::Script(script) => ScriptInterpreter::discover( - Pep723ItemRef::Script(script), + script.into(), python.as_deref().map(PythonRequest::parse), &network_settings, python_preference, @@ -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, @@ -442,6 +443,15 @@ async fn do_lock( sources, } = 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 + ); + } + // Collect the requirements, etc. let members = target.members(); let packages = target.packages(); @@ -664,6 +674,18 @@ 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_extra_build_requires((*script).into(), settings)? + } + }; let build_dispatch = BuildDispatch::new( &client, cache, @@ -677,6 +699,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 052d41bea..6b4be560b 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -13,7 +13,7 @@ use uv_cache_key::cache_digest; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ Concurrency, Constraints, DependencyGroupsWithDefaults, DryRun, ExtrasSpecification, Preview, - PreviewFeatures, Reinstall, SourceStrategy, Upgrade, + PreviewFeatures, Reinstall, Upgrade, }; use uv_dispatch::{BuildDispatch, SharedState}; use uv_distribution::{DistributionDatabase, LoweredRequirement}; @@ -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, @@ -1740,6 +1742,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, @@ -1753,6 +1757,7 @@ pub(crate) async fn resolve_names( config_setting, config_settings_package, build_isolation, + &extra_build_requires, *link_mode, build_options, &build_hasher, @@ -1845,6 +1850,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: _, @@ -1948,6 +1954,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, @@ -1961,6 +1969,7 @@ pub(crate) async fn resolve_environment( config_setting, config_settings_package, build_isolation, + &extra_build_requires, *link_mode, build_options, &build_hasher, @@ -2028,6 +2037,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, @@ -2086,6 +2096,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, @@ -2099,6 +2111,7 @@ pub(crate) async fn sync_environment( config_setting, config_settings_package, build_isolation, + &extra_build_requires, link_mode, build_options, &build_hasher, @@ -2164,6 +2177,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, @@ -2194,6 +2208,7 @@ pub(crate) async fn update_environment( link_mode, no_build_isolation, no_build_isolation_package, + extra_build_dependencies: _, prerelease, resolution, sources, @@ -2323,6 +2338,7 @@ pub(crate) async fn update_environment( config_setting, config_settings_package, build_isolation, + &extra_build_requires, *link_mode, build_options, &build_hasher, @@ -2537,40 +2553,9 @@ pub(crate) fn script_specification( return Ok(None); }; - // Determine the working directory for the script. - let script_dir = match &script { - Pep723ItemRef::Script(script) => std::path::absolute(&script.path)? - .parent() - .expect("script path has no parent") - .to_owned(), - Pep723ItemRef::Stdin(..) | Pep723ItemRef::Remote(..) => std::env::current_dir()?, - }; - - // Collect any `tool.uv.index` from the script. - let empty = Vec::default(); - let script_indexes = match settings.sources { - SourceStrategy::Enabled => script - .metadata() - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.top_level.index.as_deref()) - .unwrap_or(&empty), - SourceStrategy::Disabled => &empty, - }; - - // Collect any `tool.uv.sources` from the script. - let empty = BTreeMap::default(); - let script_sources = match settings.sources { - SourceStrategy::Enabled => script - .metadata() - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.sources.as_ref()) - .unwrap_or(&empty), - SourceStrategy::Disabled => &empty, - }; + let script_dir = script.directory()?; + let script_indexes = script.indexes(settings.sources); + let script_sources = script.sources(settings.sources); let requirements = dependencies .iter() @@ -2634,6 +2619,51 @@ pub(crate) fn script_specification( ))) } +/// Determine the extra build requires for a script. +#[allow(clippy::result_large_err)] +pub(crate) fn script_extra_build_requires( + script: Pep723ItemRef<'_>, + settings: &ResolverSettings, +) -> Result { + let script_dir = script.directory()?; + let script_indexes = script.indexes(settings.sources); + let script_sources = script.sources(settings.sources); + + // 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); + } + + Ok(uv_distribution::ExtraBuildRequires::from_lowered( + extra_build_dependencies, + )) +} + /// Warn if the user provides (e.g.) an `--index-url` in a requirements file. fn warn_on_requirements_txt_setting(spec: &RequirementsSpecification, settings: &ResolverSettings) { let RequirementsSpecification { diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 50c498833..649e6c887 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -16,7 +16,7 @@ use uv_fs::Simplified; use uv_normalize::{DEV_DEPENDENCIES, DefaultExtras, DefaultGroups}; use uv_pep508::PackageName; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; -use uv_scripts::{Pep723ItemRef, Pep723Metadata, Pep723Script}; +use uv_scripts::{Pep723Metadata, Pep723Script}; use uv_settings::PythonInstallMirrors; use uv_warnings::warn_user_once; use uv_workspace::pyproject::DependencyType; @@ -261,7 +261,7 @@ pub(crate) async fn remove( } RemoveTarget::Script(script) => { let interpreter = ScriptInterpreter::discover( - Pep723ItemRef::Script(&script), + (&script).into(), python.as_deref().map(PythonRequest::parse), &network_settings, python_preference, diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 20e11db18..65fb62ef7 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -53,8 +53,8 @@ use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ EnvironmentSpecification, PreferenceLocation, ProjectEnvironment, ProjectError, ScriptEnvironment, ScriptInterpreter, UniversalState, WorkspacePython, - default_dependency_groups, script_specification, update_environment, - validate_project_requires_python, + default_dependency_groups, script_extra_build_requires, script_specification, + update_environment, validate_project_requires_python, }; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::{ExitStatus, diagnostics, project}; @@ -359,6 +359,8 @@ 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)? { + let script_extra_build_requires = + script_extra_build_requires((&script).into(), &settings.resolver)?; 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 197fbc343..dbf483c03 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -14,7 +14,7 @@ use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ Concurrency, Constraints, DependencyGroups, DependencyGroupsWithDefaults, DryRun, EditableMode, ExtrasSpecification, ExtrasSpecificationWithDefaults, HashCheckingMode, InstallOptions, - Preview, PreviewFeatures, TargetTriple, + Preview, PreviewFeatures, TargetTriple, Upgrade, }; use uv_dispatch::BuildDispatch; use uv_distribution_types::{ @@ -26,11 +26,11 @@ 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}; -use uv_scripts::{Pep723ItemRef, Pep723Script}; +use uv_resolver::{FlatIndex, ForkStrategy, Installable, Lock, PrereleaseMode, ResolutionMode}; +use uv_scripts::Pep723Script; use uv_settings::PythonInstallMirrors; use uv_types::{BuildIsolation, HashStrategy}; -use uv_warnings::warn_user; +use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::pyproject::Source; use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace, WorkspaceCache}; @@ -43,11 +43,14 @@ use crate::commands::project::lock::{LockMode, LockOperation, LockResult}; use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ PlatformState, ProjectEnvironment, ProjectError, ScriptEnvironment, UniversalState, - default_dependency_groups, detect_conflicts, script_specification, update_environment, + default_dependency_groups, detect_conflicts, script_extra_build_requires, script_specification, + update_environment, }; 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)] @@ -164,7 +167,7 @@ pub(crate) async fn sync( ), SyncTarget::Script(script) => SyncEnvironment::Script( ScriptEnvironment::get_or_init( - Pep723ItemRef::Script(script), + script.into(), python.as_deref().map(PythonRequest::parse), &network_settings, python_preference, @@ -222,8 +225,9 @@ pub(crate) async fn sync( } // Parse the requirements from the script. - let spec = script_specification(Pep723ItemRef::Script(script), &settings.resolver)? - .unwrap_or_default(); + let spec = script_specification(script.into(), &settings.resolver)?.unwrap_or_default(); + let script_extra_build_requires = + script_extra_build_requires(script.into(), &settings.resolver)?; // Parse the build constraints from the script. let build_constraints = script @@ -248,6 +252,7 @@ pub(crate) async fn sync( spec, modifications, build_constraints.unwrap_or_default(), + script_extra_build_requires, &settings, &network_settings, &PlatformState::default(), @@ -579,6 +584,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, @@ -587,6 +593,52 @@ pub(super) async fn do_sync( sources, } = 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: exclude_newer.clone(), + 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(), + }; + script_extra_build_requires((*script).into(), &resolver_settings)? + } + }; + let client_builder = BaseClientBuilder::new() .retries_from_env()? .connectivity(network_settings.connectivity) @@ -715,10 +767,11 @@ pub(super) async fn do_sync( config_setting, config_settings_package, build_isolation, + &extra_build_requires, link_mode, build_options, &build_hasher, - exclude_newer, + exclude_newer.clone(), sources, workspace_cache.clone(), concurrency, diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 1d594bd53..1f8f46a3d 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -13,7 +13,7 @@ use uv_normalize::DefaultGroups; use uv_pep508::PackageName; use uv_python::{PythonDownloads, PythonPreference, PythonRequest, PythonVersion}; use uv_resolver::{PackageMap, TreeDisplay}; -use uv_scripts::{Pep723ItemRef, Pep723Script}; +use uv_scripts::Pep723Script; use uv_settings::PythonInstallMirrors; use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache}; @@ -86,7 +86,7 @@ pub(crate) async fn tree( } else { Some(match target { LockTarget::Script(script) => ScriptInterpreter::discover( - Pep723ItemRef::Script(script), + script.into(), python.as_deref().map(PythonRequest::parse), network_settings, python_preference, @@ -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 f391ef499..9bfa9d24d 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..0d5163e4d 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; @@ -37,7 +37,7 @@ use uv_pypi_types::{ParsedDirectoryUrl, ParsedUrl}; use uv_python::PythonRequest; use uv_requirements::{GroupsSpecification, RequirementsSource}; use uv_requirements_txt::RequirementsTxtRequirement; -use uv_scripts::{Pep723Error, Pep723Item, Pep723ItemRef, Pep723Metadata, Pep723Script}; +use uv_scripts::{Pep723Error, Pep723Item, Pep723Metadata, Pep723Script}; use uv_settings::{Combine, EnvironmentOptions, FilesystemOptions, Options}; use uv_static::EnvVars; use uv_warnings::{warn_user, warn_user_once}; @@ -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, @@ -1467,7 +1500,7 @@ async fn run(mut cli: Cli) -> Result { if let Some(Pep723Item::Script(script)) = script { commands::python_find_script( - Pep723ItemRef::Script(&script), + (&script).into(), args.show_version, &globals.network_settings, globals.python_preference, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index bc09e8257..7746f0667 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -45,7 +45,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; @@ -2714,6 +2714,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: ExcludeNewer, pub(crate) link_mode: LinkMode, pub(crate) compile_bytecode: bool, @@ -2740,6 +2741,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, @@ -2792,6 +2794,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()), @@ -2889,6 +2892,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()), @@ -2931,6 +2935,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, @@ -2998,6 +3003,7 @@ impl PipSettings { only_binary, no_build_isolation, no_build_isolation_package, + extra_build_dependencies, strict, extra, all_extras, @@ -3057,6 +3063,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, @@ -3093,6 +3100,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 = args .exclude_newer .combine(exclude_newer) @@ -3196,6 +3205,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) @@ -3303,6 +3316,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.clone(), 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 9de38cb31..6775f2e3d 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -184,6 +184,9 @@ fn resolve_uv_toml() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -378,6 +381,9 @@ fn resolve_uv_toml() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -573,6 +579,9 @@ fn resolve_uv_toml() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -800,6 +809,9 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -962,6 +974,9 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -1168,6 +1183,9 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -1422,6 +1440,9 @@ fn resolve_index_url() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -1686,6 +1707,9 @@ fn resolve_index_url() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -1905,6 +1929,9 @@ fn resolve_find_links() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -2089,6 +2116,9 @@ fn resolve_top_level() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -2333,6 +2363,9 @@ fn resolve_top_level() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -2560,6 +2593,9 @@ fn resolve_top_level() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -2743,6 +2779,9 @@ fn resolve_user_configuration() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -2910,6 +2949,9 @@ fn resolve_user_configuration() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -3077,6 +3119,9 @@ fn resolve_user_configuration() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -3246,6 +3291,9 @@ fn resolve_user_configuration() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -3407,6 +3455,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, exclude_newer_package: None, link_mode: Some( @@ -3455,6 +3504,9 @@ fn resolve_tool() -> anyhow::Result<()> { link_mode: Clone, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), prerelease: IfNecessaryOrExplicit, resolution: LowestDirect, sources: Enabled, @@ -3613,6 +3665,9 @@ fn resolve_poetry_toml() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -3848,6 +3903,9 @@ fn resolve_both() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -4087,6 +4145,9 @@ fn resolve_both_special_fields() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -4405,6 +4466,9 @@ fn resolve_config_file() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -4490,7 +4554,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`, `exclude-newer-package`, `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`, `exclude-newer-package`, `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` " ); @@ -4665,6 +4729,9 @@ fn resolve_skip_empty() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -4835,6 +4902,9 @@ fn resolve_skip_empty() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -5024,6 +5094,9 @@ fn allow_insecure_host() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -5274,6 +5347,9 @@ fn index_priority() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -5503,6 +5579,9 @@ fn index_priority() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -5738,6 +5817,9 @@ fn index_priority() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -5968,6 +6050,9 @@ fn index_priority() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -6205,6 +6290,9 @@ fn index_priority() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -6435,6 +6523,9 @@ fn index_priority() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -6609,6 +6700,9 @@ fn verify_hashes() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -6769,6 +6863,9 @@ fn verify_hashes() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -6927,6 +7024,9 @@ fn verify_hashes() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -7087,6 +7187,9 @@ fn verify_hashes() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -7245,6 +7348,9 @@ fn verify_hashes() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -7404,6 +7510,9 @@ fn verify_hashes() -> anyhow::Result<()> { torch_backend: None, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), build_options: BuildOptions { no_binary: None, no_build: None, @@ -7501,7 +7610,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, @@ -7572,6 +7681,9 @@ fn preview_features() { link_mode: Clone, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), prerelease: IfNecessaryOrExplicit, resolution: Highest, sources: Enabled, @@ -7679,6 +7791,9 @@ fn preview_features() { link_mode: Clone, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), prerelease: IfNecessaryOrExplicit, resolution: Highest, sources: Enabled, @@ -7715,7 +7830,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, @@ -7786,6 +7901,9 @@ fn preview_features() { link_mode: Clone, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), prerelease: IfNecessaryOrExplicit, resolution: Highest, sources: Enabled, @@ -7893,6 +8011,9 @@ fn preview_features() { link_mode: Clone, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), prerelease: IfNecessaryOrExplicit, resolution: Highest, sources: Enabled, @@ -8000,6 +8121,9 @@ fn preview_features() { link_mode: Clone, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), prerelease: IfNecessaryOrExplicit, resolution: Highest, sources: Enabled, @@ -8109,6 +8233,9 @@ fn preview_features() { link_mode: Clone, no_build_isolation: false, no_build_isolation_package: [], + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), prerelease: IfNecessaryOrExplicit, resolution: Highest, sources: Enabled, diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index a9dc60dcc..820899d06 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 4a1d74b42..8062f227d 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. @@ -1127,6 +1149,36 @@ Accepts package-date pairs in a dictionary format. --- +### [`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`. @@ -2616,6 +2668,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 a2f3f0113..17ae72b1d 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/ExtraBuildDependencies" + }, + { + "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": [ @@ -873,6 +884,15 @@ "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2}))?$" }, + "ExtraBuildDependencies": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/Requirement" + } + } + }, "ExtraName": { "description": "The normalized name of an extra dependency.\n\nConverts the name to lowercase and collapses runs of `-`, `_`, and `.` down to a single `-`.\nFor example, `---`, `.`, and `__` are all converted to a single `-`.\n\nSee:\n- \n- ", "type": "string" @@ -1315,6 +1335,17 @@ "$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.", + "anyOf": [ + { + "$ref": "#/definitions/ExtraBuildDependencies" + }, + { + "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). 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": [