From 8ef3b2eb8ebe9f8a00a5ce8c551e1aec12c39be0 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 5 Aug 2025 19:00:44 +0100 Subject: [PATCH] Enable extra build dependencies to 'match runtime' versions (#15036) ## Summary This is an alternative to https://github.com/astral-sh/uv/pull/14944 that functions a little differently. Rather than adding separate strategies, you can instead say: ```toml [tool.uv.extra-build-dependencies] child = [{ requirement = "anyio", match-runtime = true }] ``` Which will then enforce that `anyio` uses the same version as in the lockfile. --- crates/uv-build-frontend/src/lib.rs | 1 + .../src/build_requires.rs | 85 +++++- .../src/index/built_wheel_index.rs | 8 +- .../src/metadata/build_requires.rs | 95 +++--- crates/uv-distribution/src/source/mod.rs | 6 +- crates/uv-scripts/src/lib.rs | 5 +- crates/uv-workspace/src/pyproject.rs | 97 +++++- crates/uv/src/commands/project/mod.rs | 36 ++- crates/uv/src/commands/project/sync.rs | 3 + crates/uv/tests/it/sync.rs | 287 ++++++++++++++++++ uv.schema.json | 24 +- 11 files changed, 566 insertions(+), 81 deletions(-) diff --git a/crates/uv-build-frontend/src/lib.rs b/crates/uv-build-frontend/src/lib.rs index cfa6a5f9e..c9fe63947 100644 --- a/crates/uv-build-frontend/src/lib.rs +++ b/crates/uv-build-frontend/src/lib.rs @@ -328,6 +328,7 @@ impl SourceBuild { .and_then(|name| extra_build_requires.get(name).cloned()) .unwrap_or_default() .into_iter() + .map(Requirement::from) .collect(); // Create a virtual environment, or install into the shared environment if requested. diff --git a/crates/uv-distribution-types/src/build_requires.rs b/crates/uv-distribution-types/src/build_requires.rs index 2ec1a164e..2118be4f1 100644 --- a/crates/uv-distribution-types/src/build_requires.rs +++ b/crates/uv-distribution-types/src/build_requires.rs @@ -1,15 +1,24 @@ use std::collections::BTreeMap; +use uv_cache_key::{CacheKey, CacheKeyHasher}; use uv_normalize::PackageName; -use crate::Requirement; +use crate::{Name, Requirement, RequirementSource, Resolution}; + +#[derive(Debug, thiserror::Error)] +pub enum ExtraBuildRequiresError { + #[error( + "`{0}` was declared as an extra build dependency with `match-runtime = true`, but was not found in the resolution" + )] + NotFound(PackageName), +} /// Lowered extra build dependencies with source resolution applied. #[derive(Debug, Clone, Default)] -pub struct ExtraBuildRequires(BTreeMap>); +pub struct ExtraBuildRequires(BTreeMap>); impl std::ops::Deref for ExtraBuildRequires { - type Target = BTreeMap>; + type Target = BTreeMap>; fn deref(&self) -> &Self::Target { &self.0 @@ -23,16 +32,78 @@ impl std::ops::DerefMut for ExtraBuildRequires { } impl IntoIterator for ExtraBuildRequires { - type Item = (PackageName, Vec); - type IntoIter = std::collections::btree_map::IntoIter>; + type Item = (PackageName, Vec); + type IntoIter = std::collections::btree_map::IntoIter>; fn into_iter(self) -> Self::IntoIter { self.0.into_iter() } } -impl FromIterator<(PackageName, Vec)> for ExtraBuildRequires { - fn from_iter)>>(iter: T) -> Self { +impl FromIterator<(PackageName, Vec)> for ExtraBuildRequires { + fn from_iter)>>( + iter: T, + ) -> Self { Self(iter.into_iter().collect()) } } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtraBuildRequirement { + /// The underlying [`Requirement`] for the build requirement. + pub requirement: Requirement, + /// Whether this build requirement should match the runtime environment. + pub match_runtime: bool, +} + +impl From for Requirement { + fn from(value: ExtraBuildRequirement) -> Self { + value.requirement + } +} + +impl CacheKey for ExtraBuildRequirement { + fn cache_key(&self, state: &mut CacheKeyHasher) { + self.requirement.cache_key(state); + self.match_runtime.cache_key(state); + } +} + +impl ExtraBuildRequires { + /// Apply runtime constraints from a resolution to the extra build requirements. + pub fn match_runtime( + self, + resolution: &Resolution, + ) -> Result { + self.into_iter() + .map(|(name, requirements)| { + let requirements = requirements + .into_iter() + .map(|requirement| match requirement { + ExtraBuildRequirement { + requirement, + match_runtime: true, + } => { + let dist = resolution + .distributions() + .find(|dist| dist.name() == &requirement.name) + .ok_or_else(|| { + ExtraBuildRequiresError::NotFound(requirement.name.clone()) + })?; + let requirement = Requirement { + source: RequirementSource::from(dist), + ..requirement + }; + Ok::<_, ExtraBuildRequiresError>(ExtraBuildRequirement { + requirement, + match_runtime: true, + }) + } + requirement => Ok(requirement), + }) + .collect::, _>>()?; + Ok::<_, ExtraBuildRequiresError>((name, requirements)) + }) + .collect::>() + } +} diff --git a/crates/uv-distribution/src/index/built_wheel_index.rs b/crates/uv-distribution/src/index/built_wheel_index.rs index 321199772..c24baeab2 100644 --- a/crates/uv-distribution/src/index/built_wheel_index.rs +++ b/crates/uv-distribution/src/index/built_wheel_index.rs @@ -5,8 +5,8 @@ use uv_cache_info::CacheInfo; use uv_cache_key::cache_digest; use uv_configuration::{ConfigSettings, PackageConfigSettings}; use uv_distribution_types::{ - DirectUrlSourceDist, DirectorySourceDist, ExtraBuildRequires, GitSourceDist, Hashed, - PathSourceDist, Requirement, + DirectUrlSourceDist, DirectorySourceDist, ExtraBuildRequirement, ExtraBuildRequires, + GitSourceDist, Hashed, PathSourceDist, }; use uv_normalize::PackageName; use uv_platform_tags::Tags; @@ -267,8 +267,8 @@ impl<'a> BuiltWheelIndex<'a> { } } - /// Determine the extra build dependencies for the given package name. - fn extra_build_requires_for(&self, name: &PackageName) -> &[Requirement] { + /// Determine the extra build requirements for the given package name. + fn extra_build_requires_for(&self, name: &PackageName) -> &[ExtraBuildRequirement] { self.extra_build_requires .get(name) .map(Vec::as_slice) diff --git a/crates/uv-distribution/src/metadata/build_requires.rs b/crates/uv-distribution/src/metadata/build_requires.rs index 3913d292c..723ca4f96 100644 --- a/crates/uv-distribution/src/metadata/build_requires.rs +++ b/crates/uv-distribution/src/metadata/build_requires.rs @@ -2,9 +2,11 @@ use std::collections::BTreeMap; use std::path::Path; use uv_configuration::SourceStrategy; -use uv_distribution_types::{ExtraBuildRequires, IndexLocations, Requirement}; +use uv_distribution_types::{ + ExtraBuildRequirement, ExtraBuildRequires, IndexLocations, Requirement, +}; use uv_normalize::PackageName; -use uv_workspace::pyproject::{ExtraBuildDependencies, ToolUvSources}; +use uv_workspace::pyproject::{ExtraBuildDependencies, ExtraBuildDependency, ToolUvSources}; use uv_workspace::{ DiscoveryOptions, MemberDiscovery, ProjectWorkspace, Workspace, WorkspaceCache, }; @@ -248,50 +250,48 @@ impl LoweredExtraBuildDependencies { // Lower each package's extra build dependencies let mut build_requires = ExtraBuildRequires::default(); for (package_name, requirements) in extra_build_dependencies { - let lowered: Vec = requirements + 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()), - Err(err) => Err(MetadataError::LoweringError( - requirement_name.clone(), - Box::new(err), - )), - }, - ) - }) + .flat_map( + |ExtraBuildDependency { + requirement, + match_runtime, + }| { + 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(ExtraBuildRequirement { + requirement: requirement.into_inner(), + match_runtime, + }), + Err(err) => Err(MetadataError::LoweringError( + requirement_name.clone(), + Box::new(err), + )), + } + }) + }, + ) .collect::, _>>()?; build_requires.insert(package_name, lowered); } Ok(Self(build_requires)) } - SourceStrategy::Disabled => Ok(Self( - extra_build_dependencies - .into_iter() - .map(|(name, requirements)| { - ( - name, - requirements.into_iter().map(Requirement::from).collect(), - ) - }) - .collect(), - )), + SourceStrategy::Disabled => Ok(Self::from_non_lowered(extra_build_dependencies)), } } @@ -308,7 +308,20 @@ impl LoweredExtraBuildDependencies { .map(|(name, requirements)| { ( name, - requirements.into_iter().map(Requirement::from).collect(), + requirements + .into_iter() + .map( + |ExtraBuildDependency { + requirement, + match_runtime, + }| { + ExtraBuildRequirement { + requirement: requirement.into(), + match_runtime, + } + }, + ) + .collect::>(), ) }) .collect(), diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index 05d25be42..abcb57960 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -32,8 +32,8 @@ use uv_client::{ use uv_configuration::{BuildKind, BuildOutput, ConfigSettings, SourceStrategy}; use uv_distribution_filename::{SourceDistExtension, WheelFilename}; use uv_distribution_types::{ - BuildableSource, DirectorySourceUrl, GitSourceUrl, HashPolicy, Hashed, IndexUrl, PathSourceUrl, - Requirement, SourceDist, SourceUrl, + BuildableSource, DirectorySourceUrl, ExtraBuildRequirement, GitSourceUrl, HashPolicy, Hashed, + IndexUrl, PathSourceUrl, SourceDist, SourceUrl, }; use uv_extract::hash::Hasher; use uv_fs::{rename_with_retry, write_atomic}; @@ -405,7 +405,7 @@ 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>) -> &[Requirement] { + fn extra_build_dependencies_for(&self, name: Option<&PackageName>) -> &[ExtraBuildRequirement] { name.and_then(|name| { self.build_context .extra_build_requires() diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index 474b1f91b..c70c2a354 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -16,7 +16,7 @@ use uv_pypi_types::VerbatimParsedUrl; use uv_redacted::DisplaySafeUrl; use uv_settings::{GlobalOptions, ResolverInstallerOptions}; use uv_warnings::warn_user; -use uv_workspace::pyproject::Sources; +use uv_workspace::pyproject::{ExtraBuildDependency, Sources}; static FINDER: LazyLock = LazyLock::new(|| Finder::new(b"# /// script")); @@ -428,8 +428,7 @@ pub struct ToolUv { pub override_dependencies: Option>>, pub constraint_dependencies: Option>>, pub build_constraint_dependencies: Option>>, - pub extra_build_dependencies: - Option>>>, + pub extra_build_dependencies: Option>>, pub sources: Option>, } diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 5b933a130..a54ac1497 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -28,7 +28,7 @@ use uv_macros::OptionsMetadata; use uv_normalize::{DefaultGroups, ExtraName, GroupName, PackageName}; use uv_options_metadata::{OptionSet, OptionsMetadata, Visit}; use uv_pep440::{Version, VersionSpecifiers}; -use uv_pep508::MarkerTree; +use uv_pep508::{MarkerTree, VersionOrUrl}; use uv_pypi_types::{ Conflicts, DependencyGroups, SchemaConflicts, SupportedEnvironments, VerbatimParsedUrl, }; @@ -755,14 +755,86 @@ pub struct DependencyGroupSettings { pub requires_python: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged, rename_all = "kebab-case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum ExtraBuildDependencyWire { + Unannotated(uv_pep508::Requirement), + #[serde(rename_all = "kebab-case")] + Annotated { + requirement: uv_pep508::Requirement, + match_runtime: bool, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + deny_unknown_fields, + try_from = "ExtraBuildDependencyWire", + into = "ExtraBuildDependencyWire" +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ExtraBuildDependency { + pub requirement: uv_pep508::Requirement, + pub match_runtime: bool, +} + +impl From for uv_pep508::Requirement { + fn from(value: ExtraBuildDependency) -> Self { + value.requirement + } +} + +#[derive(Error, Debug)] +pub enum ExtraBuildDependencyError { + #[error("Dependencies marked with `match-runtime = true` cannot include version specifiers")] + VersionSpecifiersNotAllowed, + #[error("Dependencies marked with `match-runtime = true` cannot include URL constraints")] + UrlNotAllowed, +} + +impl TryFrom for ExtraBuildDependency { + type Error = ExtraBuildDependencyError; + + fn try_from(wire: ExtraBuildDependencyWire) -> Result { + match wire { + ExtraBuildDependencyWire::Unannotated(requirement) => Ok(Self { + requirement, + match_runtime: false, + }), + ExtraBuildDependencyWire::Annotated { + requirement, + match_runtime, + } => match requirement.version_or_url { + None => Ok(Self { + requirement, + match_runtime, + }), + // If `match-runtime = true`, reject additional constraints. + Some(VersionOrUrl::VersionSpecifier(..)) => { + Err(ExtraBuildDependencyError::VersionSpecifiersNotAllowed) + } + Some(VersionOrUrl::Url(..)) => Err(ExtraBuildDependencyError::UrlNotAllowed), + }, + } + } +} + +impl From for ExtraBuildDependencyWire { + fn from(item: ExtraBuildDependency) -> ExtraBuildDependencyWire { + ExtraBuildDependencyWire::Annotated { + requirement: item.requirement, + match_runtime: item.match_runtime, + } + } +} + #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct ExtraBuildDependencies( - BTreeMap>>, -); +pub struct ExtraBuildDependencies(BTreeMap>); impl std::ops::Deref for ExtraBuildDependencies { - type Target = BTreeMap>>; + type Target = BTreeMap>; fn deref(&self) -> &Self::Target { &self.0 @@ -776,17 +848,22 @@ impl std::ops::DerefMut for ExtraBuildDependencies { } impl IntoIterator for ExtraBuildDependencies { - type Item = (PackageName, Vec>); - type IntoIter = std::collections::btree_map::IntoIter< - PackageName, - Vec>, - >; + type Item = (PackageName, Vec); + type IntoIter = std::collections::btree_map::IntoIter>; fn into_iter(self) -> Self::IntoIter { self.0.into_iter() } } +impl FromIterator<(PackageName, Vec)> for ExtraBuildDependencies { + fn from_iter)>>( + iter: T, + ) -> Self { + Self(iter.into_iter().collect()) + } +} + /// Ensure that all keys in the TOML table are unique. impl<'de> serde::de::Deserialize<'de> for ExtraBuildDependencies { fn deserialize(deserializer: D) -> Result diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 2c799dfe2..e474ad106 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -18,8 +18,8 @@ use uv_configuration::{ use uv_dispatch::{BuildDispatch, SharedState}; use uv_distribution::{DistributionDatabase, LoweredExtraBuildDependencies, LoweredRequirement}; use uv_distribution_types::{ - ExtraBuildRequires, Index, Requirement, RequiresPython, Resolution, UnresolvedRequirement, - UnresolvedRequirementSpecification, + ExtraBuildRequirement, ExtraBuildRequires, Index, Requirement, RequiresPython, Resolution, + UnresolvedRequirement, UnresolvedRequirementSpecification, }; use uv_fs::{CWD, LockedFile, Simplified}; use uv_git::ResolvedRepositoryReference; @@ -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::ExtraBuildDependency; use uv_workspace::pyproject::PyProjectToml; use uv_workspace::{RequiresPythonSources, Workspace, WorkspaceCache}; @@ -252,6 +253,9 @@ pub(crate) enum ProjectError { #[error(transparent)] PyprojectMut(#[from] uv_workspace::pyproject_mut::Error), + #[error(transparent)] + ExtraBuildRequires(#[from] uv_distribution_types::ExtraBuildRequiresError), + #[error(transparent)] Fmt(#[from] std::fmt::Error), @@ -2647,16 +2651,24 @@ pub(crate) fn script_extra_build_requires( 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(uv_distribution::LoweredRequirement::into_inner) - }) + .flat_map( + |ExtraBuildDependency { + requirement, + match_runtime, + }| { + LoweredRequirement::from_non_workspace_requirement( + requirement, + script_dir.as_ref(), + script_sources, + script_indexes, + &settings.index_locations, + ) + .map_ok(move |requirement| ExtraBuildRequirement { + requirement: requirement.into_inner(), + match_runtime, + }) + }, + ) .collect::, _>>()?; extra_build_requires.insert(name.clone(), lowered_requirements); } diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 3d7a1011a..24b01eb30 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -713,6 +713,9 @@ pub(super) async fn do_sync( // If necessary, convert editable to non-editable distributions. let resolution = apply_editable_mode(resolution, editable); + // Constrain any build requirements marked as `match-runtime = true`. + let extra_build_requires = extra_build_requires.match_runtime(&resolution)?; + index_locations.cache_index_credentials(); // Populate credentials from the target. diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 1e46314d4..edda357d8 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -12479,3 +12479,290 @@ fn sync_does_not_remove_empty_virtual_environment_directory() -> Result<()> { Ok(()) } + +/// Test that build dependencies respect locked versions from the lockfile. +#[test] +fn sync_build_dependencies_respect_locked_versions() -> 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", "anyio"] + backend-path = ["."] + build-backend = "build_backend" + "#})?; + + // Create a build backend that checks for a specific version of anyio + let build_backend = child.child("build_backend.py"); + build_backend.write_str(indoc! {r#" + import os + import sys + from hatchling.build import * + + expected_version = os.environ.get("EXPECTED_ANYIO_VERSION", "") + if not expected_version: + print("`EXPECTED_ANYIO_VERSION` not set", file=sys.stderr) + sys.exit(1) + + try: + import anyio + except ModuleNotFoundError: + print("Missing `anyio` module", file=sys.stderr) + sys.exit(1) + + from importlib.metadata import version + anyio_version = version("anyio") + + if not anyio_version.startswith(expected_version): + print(f"Expected `anyio` version {expected_version} but got {anyio_version}", file=sys.stderr) + sys.exit(1) + + print(f"Found expected `anyio` version {anyio_version}", file=sys.stderr) + "#})?; + child.child("src/child/__init__.py").touch()?; + + // Create a project that will resolve to a non-latest version of `anyio` + 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 = ["anyio<4.1"] + "#})?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + "); + + // Now add the child dependency. + pyproject_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = ["anyio<4.1", "child"] + + [tool.uv.sources] + child = { path = "child" } + "#})?; + + // Ensure our build backend is checking the version correctly + uv_snapshot!(context.filters(), context.sync().env("EXPECTED_ANYIO_VERSION", "3.0"), @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] + Expected `anyio` version 3.0 but got 4.3.0 + + 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` + "); + + // Now constrain the `anyio` build dependency to match the runtime + pyproject_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = ["anyio<4.1", "child"] + + [tool.uv.sources] + child = { path = "child" } + + [tool.uv.extra-build-dependencies] + child = [{ requirement = "anyio", match-runtime = true }] + "#})?; + + // The child should be built with anyio 4.0 + uv_snapshot!(context.filters(), context.sync().env("EXPECTED_ANYIO_VERSION", "4.0"), @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] + + anyio==4.0.0 + + child==0.1.0 (from file://[TEMP_DIR]/child) + + idna==3.6 + + sniffio==1.3.1 + "); + + // Change the constraints on anyio + pyproject_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = ["anyio<3.8", "child"] + + [tool.uv.sources] + child = { path = "child" } + + [tool.uv.extra-build-dependencies] + child = [{ requirement = "anyio", match-runtime = true }] + "#})?; + + // The child should be rebuilt with anyio 3.7, without `--reinstall` + uv_snapshot!(context.filters(), context.sync() + .arg("--reinstall-package").arg("child").env("EXPECTED_ANYIO_VERSION", "4.0"), @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] + Expected `anyio` version 4.0 but got 3.7.1 + + 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` + "); + + uv_snapshot!(context.filters(), context.sync() + .arg("--reinstall-package").arg("child").env("EXPECTED_ANYIO_VERSION", "3.7"), @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] + Uninstalled [N] packages in [TIME] + Installed [N] packages in [TIME] + - anyio==4.0.0 + + anyio==3.7.1 + ~ child==0.1.0 (from file://[TEMP_DIR]/child) + "); + + // With preview enabled, there's no warning + uv_snapshot!(context.filters(), context.sync() + .arg("--preview-features").arg("extra-build-dependencies") + .arg("--reinstall-package").arg("child") + .env("EXPECTED_ANYIO_VERSION", "3.7"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Uninstalled [N] packages in [TIME] + Installed [N] packages in [TIME] + ~ child==0.1.0 (from file://[TEMP_DIR]/child) + "); + + // Now, we'll set a constraint in the parent project + pyproject_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = ["anyio<3.8", "child"] + + [tool.uv.sources] + child = { path = "child" } + + [tool.uv.extra-build-dependencies] + child = [{ requirement = "anyio", match-runtime = true }] + "#})?; + + // And an incompatible constraint in the child project + child_pyproject_toml.write_str(indoc! {r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.9" + + [build-system] + requires = ["hatchling", "anyio>3.8,<4.2"] + backend-path = ["."] + build-backend = "build_backend" + "#})?; + + // This should fail + uv_snapshot!(context.filters(), context.sync() + .arg("--reinstall-package").arg("child").env("EXPECTED_ANYIO_VERSION", "4.1"), @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` + ├─▶ Failed to resolve requirements from `build-system.requires` and `extra-build-dependencies` + ├─▶ No solution found when resolving: `hatchling`, `anyio>3.8, <4.2`, `anyio==3.7.1 (index: https://pypi.org/simple)` + ╰─▶ Because you require anyio>3.8,<4.2 and anyio==3.7.1, we can conclude that your requirements are unsatisfiable. + help: `child` was included because `parent` (v0.1.0) depends on `child` + "); + + // Adding a version specifier should also fail + pyproject_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = ["anyio<4.1", "child"] + + [tool.uv.sources] + child = { path = "child" } + + [tool.uv.extra-build-dependencies] + child = [{ requirement = "anyio>4", match-runtime = true }] + "#})?; + + uv_snapshot!(context.filters(), context.sync(), @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: Failed to parse `pyproject.toml` during settings discovery: + TOML parse error at line 11, column 9 + | + 11 | child = [{ requirement = "anyio>4", match-runtime = true }] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + Dependencies marked with `match-runtime = true` cannot include version specifiers + + error: Failed to parse: `pyproject.toml` + Caused by: TOML parse error at line 11, column 9 + | + 11 | child = [{ requirement = "anyio>4", match-runtime = true }] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + Dependencies marked with `match-runtime = true` cannot include version specifiers + "#); + + Ok(()) +} diff --git a/uv.schema.json b/uv.schema.json index 17ae72b1d..d0d4051d3 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -889,10 +889,32 @@ "additionalProperties": { "type": "array", "items": { - "$ref": "#/definitions/Requirement" + "$ref": "#/definitions/ExtraBuildDependency" } } }, + "ExtraBuildDependency": { + "anyOf": [ + { + "$ref": "#/definitions/Requirement" + }, + { + "type": "object", + "properties": { + "match-runtime": { + "type": "boolean" + }, + "requirement": { + "$ref": "#/definitions/Requirement" + } + }, + "required": [ + "requirement", + "match-runtime" + ] + } + ] + }, "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"