From 6856a27711910d240a488d53d7967b73340b1524 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 30 Jul 2025 09:53:07 -0500 Subject: [PATCH] Add `extra-build-dependencies` (#14735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces https://github.com/astral-sh/uv/pull/14092 Adds `tool.uv.extra-build-dependencies = {package = [dependency, ...]}` which extends `build-system.requires` during package builds. These are lowered via workspace sources, are applied to transitive dependencies, and are included in the wheel cache shard hash. There are some features we need to follow-up on, but are out of scope here: - Preferring locked versions for build dependencies - Settings for requiring locked versions for build depencies There are some quality of life follow-ups we should also do: - Warn on `extra-build-dependencies` that do not apply to any packages - Add test cases and improve error messaging when the `extra-build-dependencies` resolve fails ------- There ~are~ were a few open decisions to be made here 1. Should we resolve these dependencies alongside the `build-system.requires` dependencies? Or should we resolve separately? (I think the latter is more powerful? because you can override things? but it opens the door to breaking your build) 2. Should we install these dependencies into the same environment? Or should we layer it on top as we do elsewhere? (I think it's fine to install into the same environment) 3. Should we respect sources defined in the parent project? (I think yes, but then we need to lower the dependencies earlier — I don't think that's a big deal, but it's not implemented) 4. Should we respect sources defined in the child project? (I think no, this gets really complicated and seems weird to allow) 5. Should we apply this to transitive dependencies? (I think so) --------- Co-authored-by: Aria Desires Co-authored-by: konstin --- Cargo.lock | 4 + crates/uv-bench/benches/uv.rs | 2 + crates/uv-build-frontend/src/lib.rs | 48 +- crates/uv-cli/src/options.rs | 2 + crates/uv-configuration/src/preview.rs | 7 + crates/uv-dispatch/src/lib.rs | 9 + crates/uv-distribution/src/lib.rs | 4 +- .../src/metadata/build_requires.rs | 93 ++- crates/uv-distribution/src/metadata/mod.rs | 2 +- crates/uv-distribution/src/source/mod.rs | 70 ++- crates/uv-pep440/Cargo.toml | 1 + crates/uv-pep440/src/version.rs | 107 +++- crates/uv-pep440/src/version_specifier.rs | 5 + crates/uv-pep508/Cargo.toml | 1 + crates/uv-pep508/src/lib.rs | 47 ++ crates/uv-scripts/Cargo.toml | 2 + crates/uv-scripts/src/lib.rs | 49 ++ crates/uv-settings/src/combine.rs | 45 +- crates/uv-settings/src/lib.rs | 4 + crates/uv-settings/src/settings.rs | 38 +- crates/uv-types/src/traits.rs | 3 + crates/uv-workspace/src/pyproject.rs | 183 ++++-- crates/uv-workspace/src/workspace.rs | 6 + crates/uv/src/commands/build_frontend.rs | 7 + crates/uv/src/commands/pip/compile.rs | 20 +- crates/uv/src/commands/pip/install.rs | 16 +- crates/uv/src/commands/pip/sync.rs | 16 +- crates/uv/src/commands/project/add.rs | 26 +- crates/uv/src/commands/project/export.rs | 4 +- crates/uv/src/commands/project/lock.rs | 31 +- crates/uv/src/commands/project/mod.rs | 100 +-- crates/uv/src/commands/project/remove.rs | 4 +- crates/uv/src/commands/project/run.rs | 7 +- crates/uv/src/commands/project/sync.rs | 73 ++- crates/uv/src/commands/project/tree.rs | 5 +- crates/uv/src/commands/tool/install.rs | 3 +- crates/uv/src/commands/tool/upgrade.rs | 3 +- crates/uv/src/commands/venv.rs | 5 +- crates/uv/src/lib.rs | 39 +- crates/uv/src/settings.rs | 16 +- crates/uv/tests/it/pip_install.rs | 5 +- crates/uv/tests/it/show_settings.rs | 133 +++- crates/uv/tests/it/sync.rs | 576 ++++++++++++++++++ docs/reference/settings.md | 84 +++ .../packages/anyio_local/anyio/__init__.py | 2 + uv.schema.json | 31 + 46 files changed, 1744 insertions(+), 194 deletions(-) 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": [