diff --git a/crates/uv-distribution-types/src/build_requires.rs b/crates/uv-distribution-types/src/build_requires.rs index 2118be4f1..e799e0fee 100644 --- a/crates/uv-distribution-types/src/build_requires.rs +++ b/crates/uv-distribution-types/src/build_requires.rs @@ -71,10 +71,7 @@ impl CacheKey for ExtraBuildRequirement { impl ExtraBuildRequires { /// Apply runtime constraints from a resolution to the extra build requirements. - pub fn match_runtime( - self, - resolution: &Resolution, - ) -> Result { + pub fn match_runtime(self, resolution: &Resolution) -> Result { self.into_iter() .map(|(name, requirements)| { let requirements = requirements @@ -104,6 +101,6 @@ impl ExtraBuildRequires { .collect::, _>>()?; Ok::<_, ExtraBuildRequiresError>((name, requirements)) }) - .collect::>() + .collect::>() } } diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 321e65d63..176c2b10e 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -821,8 +821,8 @@ impl TryFrom for ExtraBuildDependency { } impl From for ExtraBuildDependencyWire { - fn from(item: ExtraBuildDependency) -> ExtraBuildDependencyWire { - ExtraBuildDependencyWire::Annotated { + fn from(item: ExtraBuildDependency) -> Self { + Self::Annotated { requirement: item.requirement, match_runtime: item.match_runtime, } diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index d02623faa..da61a0087 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -511,7 +511,7 @@ pub(crate) async fn pip_install( .resolution_mode(resolution_mode) .prerelease_mode(prerelease_mode) .dependency_mode(dependency_mode) - .exclude_newer(exclude_newer) + .exclude_newer(exclude_newer.clone()) .index_strategy(index_strategy) .torch_backend(torch_backend) .build_options(build_options.clone()) @@ -559,6 +559,34 @@ pub(crate) async fn pip_install( (resolution, hasher) }; + // Constrain any build requirements marked as `match-runtime = true`. + let extra_build_requires = extra_build_requires.match_runtime(&resolution)?; + + // Create a build dispatch. + let build_dispatch = BuildDispatch::new( + &client, + &cache, + &build_constraints, + interpreter, + &index_locations, + &flat_index, + &dependency_metadata, + state.clone(), + index_strategy, + config_settings, + config_settings_package, + build_isolation, + &extra_build_requires, + link_mode, + &build_options, + &hasher, + exclude_newer.clone(), + sources, + WorkspaceCache::default(), + concurrency, + preview, + ); + // Sync the environment. match operations::install( &resolution, diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index 5254c886a..1b534dd96 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -449,7 +449,7 @@ pub(crate) async fn pip_sync( .resolution_mode(resolution_mode) .prerelease_mode(prerelease_mode) .dependency_mode(dependency_mode) - .exclude_newer(exclude_newer) + .exclude_newer(exclude_newer.clone()) .index_strategy(index_strategy) .torch_backend(torch_backend) .build_options(build_options.clone()) @@ -496,6 +496,34 @@ pub(crate) async fn pip_sync( (resolution, hasher) }; + // Constrain any build requirements marked as `match-runtime = true`. + let extra_build_requires = extra_build_requires.match_runtime(&resolution)?; + + // Create a build dispatch. + let build_dispatch = BuildDispatch::new( + &client, + &cache, + &build_constraints, + interpreter, + &index_locations, + &flat_index, + &dependency_metadata, + state.clone(), + index_strategy, + config_settings, + config_settings_package, + build_isolation, + &extra_build_requires, + link_mode, + &build_options, + &build_hasher, + exclude_newer.clone(), + sources, + WorkspaceCache::default(), + concurrency, + preview, + ); + // Sync the environment. match operations::install( &resolution, diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index fc8dbf344..0d374d8c4 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -12033,3 +12033,213 @@ fn config_settings_package() -> Result<()> { Ok(()) } + +/// Test that build dependencies respect locked versions from the resolution. +#[test] +fn pip_install_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"] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + "#})?; + context + .temp_dir + .child("src") + .child("parent") + .child("__init__.py") + .touch()?; + + // 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.pip_install().arg(".").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.pip_install().arg(".").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 + + parent==0.1.0 (from file://[TEMP_DIR]/) + + 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.pip_install().arg(".") + .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.pip_install().arg(".") + .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) + ~ parent==0.1.0 (from file://[TEMP_DIR]/) + "); + + // With preview enabled, there's no warning + uv_snapshot!(context.filters(), context.pip_install().arg(".") + .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) + ~ parent==0.1.0 (from file://[TEMP_DIR]/) + "); + + Ok(()) +}