From 1480f81712b3f1a41c9110d9f9b5d1acfbf04848 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 28 Jul 2025 12:27:53 -0500 Subject: [PATCH 1/7] Prefer locked versions of packages in build environments --- crates/uv-bench/benches/uv.rs | 5 +- crates/uv-dispatch/src/lib.rs | 9 +- crates/uv-resolver/src/manifest.rs | 6 + crates/uv/src/commands/build_frontend.rs | 3 +- crates/uv/src/commands/pip/compile.rs | 5 +- crates/uv/src/commands/pip/install.rs | 5 +- crates/uv/src/commands/pip/sync.rs | 5 +- crates/uv/src/commands/project/add.rs | 32 ++++- crates/uv/src/commands/project/lock.rs | 22 +++- crates/uv/src/commands/project/mod.rs | 8 +- crates/uv/src/commands/project/sync.rs | 14 ++- crates/uv/src/commands/venv.rs | 3 +- crates/uv/tests/it/edit.rs | 148 ++++++++++++++++++++++ crates/uv/tests/it/sync.rs | 149 +++++++++++++++++++++++ 14 files changed, 396 insertions(+), 18 deletions(-) diff --git a/crates/uv-bench/benches/uv.rs b/crates/uv-bench/benches/uv.rs index eedc0f92f0ff9..2ba7a550c2a8b 100644 --- a/crates/uv-bench/benches/uv.rs +++ b/crates/uv-bench/benches/uv.rs @@ -99,8 +99,8 @@ mod resolver { use uv_pypi_types::{Conflicts, ResolverMarkerEnvironment}; use uv_python::Interpreter; use uv_resolver::{ - FlatIndex, InMemoryIndex, Manifest, OptionsBuilder, PythonRequirement, Resolver, - ResolverEnvironment, ResolverOutput, + FlatIndex, InMemoryIndex, Manifest, OptionsBuilder, Preferences, PythonRequirement, + Resolver, ResolverEnvironment, ResolverOutput, }; use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_workspace::WorkspaceCache; @@ -195,6 +195,7 @@ mod resolver { workspace_cache, concurrency, Preview::default(), + Preferences::default(), ); let markers = if universal { diff --git a/crates/uv-dispatch/src/lib.rs b/crates/uv-dispatch/src/lib.rs index aa48fecf7fb89..5474e8af84223 100644 --- a/crates/uv-dispatch/src/lib.rs +++ b/crates/uv-dispatch/src/lib.rs @@ -32,7 +32,7 @@ use uv_installer::{Installer, Plan, Planner, Preparer, SitePackages}; use uv_pypi_types::Conflicts; use uv_python::{Interpreter, PythonEnvironment}; use uv_resolver::{ - ExcludeNewer, FlatIndex, Flexibility, InMemoryIndex, Manifest, OptionsBuilder, + ExcludeNewer, FlatIndex, Flexibility, InMemoryIndex, Manifest, OptionsBuilder, Preferences, PythonRequirement, Resolver, ResolverEnvironment, }; use uv_types::{ @@ -100,6 +100,7 @@ pub struct BuildDispatch<'a> { workspace_cache: WorkspaceCache, concurrency: Concurrency, preview: Preview, + preferences: Preferences, } impl<'a> BuildDispatch<'a> { @@ -124,6 +125,7 @@ impl<'a> BuildDispatch<'a> { workspace_cache: WorkspaceCache, concurrency: Concurrency, preview: Preview, + preferences: Preferences, ) -> Self { Self { client, @@ -148,6 +150,7 @@ impl<'a> BuildDispatch<'a> { workspace_cache, concurrency, preview, + preferences, } } @@ -229,7 +232,9 @@ impl BuildContext for BuildDispatch<'_> { let tags = self.interpreter.tags()?; let resolver = Resolver::new( - Manifest::simple(requirements.to_vec()).with_constraints(self.constraints.clone()), + Manifest::simple(requirements.to_vec()) + .with_constraints(self.constraints.clone()) + .with_preferences(self.preferences.clone()), OptionsBuilder::new() .exclude_newer(self.exclude_newer) .index_strategy(self.index_strategy) diff --git a/crates/uv-resolver/src/manifest.rs b/crates/uv-resolver/src/manifest.rs index 6533a3aab80ff..ec6d5941018b0 100644 --- a/crates/uv-resolver/src/manifest.rs +++ b/crates/uv-resolver/src/manifest.rs @@ -92,6 +92,12 @@ impl Manifest { self } + #[must_use] + pub fn with_preferences(mut self, preferences: Preferences) -> Self { + self.preferences = preferences; + self + } + /// Return an iterator over all requirements, constraints, and overrides, in priority order, /// such that requirements come first, followed by constraints, followed by overrides. /// diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index 24cda1cf39852..b613cf30e6644 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -35,7 +35,7 @@ use uv_python::{ VersionRequest, }; use uv_requirements::RequirementsSource; -use uv_resolver::{ExcludeNewer, FlatIndex}; +use uv_resolver::{ExcludeNewer, FlatIndex, Preferences}; use uv_settings::PythonInstallMirrors; use uv_types::{AnyErrorBuild, BuildContext, BuildIsolation, BuildStack, HashStrategy}; use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache, WorkspaceError}; @@ -581,6 +581,7 @@ async fn build_package( workspace_cache, concurrency, preview, + Preferences::default(), ); prepare_output_directory(&output_dir).await?; diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index 8c512a2e91155..156905ff13225 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -39,8 +39,8 @@ use uv_requirements::{ }; use uv_resolver::{ AnnotationStyle, DependencyMode, DisplayResolutionGraph, ExcludeNewer, FlatIndex, ForkStrategy, - InMemoryIndex, OptionsBuilder, PrereleaseMode, PylockToml, PythonRequirement, ResolutionMode, - ResolverEnvironment, + InMemoryIndex, OptionsBuilder, Preferences, PrereleaseMode, PylockToml, PythonRequirement, + ResolutionMode, ResolverEnvironment, }; use uv_torch::{TorchMode, TorchStrategy}; use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy}; @@ -490,6 +490,7 @@ pub(crate) async fn pip_compile( WorkspaceCache::default(), concurrency, preview, + Preferences::default(), ); let options = OptionsBuilder::new() diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index d3917db1116c0..9b7f85c9d7dfc 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -31,8 +31,8 @@ use uv_python::{ }; use uv_requirements::{GroupsSpecification, RequirementsSource, RequirementsSpecification}; use uv_resolver::{ - DependencyMode, ExcludeNewer, FlatIndex, OptionsBuilder, PrereleaseMode, PylockToml, - PythonRequirement, ResolutionMode, ResolverEnvironment, + DependencyMode, ExcludeNewer, FlatIndex, OptionsBuilder, Preferences, PrereleaseMode, + PylockToml, PythonRequirement, ResolutionMode, ResolverEnvironment, }; use uv_torch::{TorchMode, TorchStrategy}; use uv_types::{BuildIsolation, HashStrategy}; @@ -434,6 +434,7 @@ pub(crate) async fn pip_install( WorkspaceCache::default(), concurrency, preview, + Preferences::default(), ); let (resolution, hasher) = if let Some(pylock) = pylock { diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index 2053f535b3475..a7bd366523ff1 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -27,8 +27,8 @@ use uv_python::{ }; use uv_requirements::{GroupsSpecification, RequirementsSource, RequirementsSpecification}; use uv_resolver::{ - DependencyMode, ExcludeNewer, FlatIndex, OptionsBuilder, PrereleaseMode, PylockToml, - PythonRequirement, ResolutionMode, ResolverEnvironment, + DependencyMode, ExcludeNewer, FlatIndex, OptionsBuilder, Preferences, PrereleaseMode, + PylockToml, PythonRequirement, ResolutionMode, ResolverEnvironment, }; use uv_torch::{TorchMode, TorchStrategy}; use uv_types::{BuildIsolation, HashStrategy}; @@ -369,6 +369,7 @@ pub(crate) async fn pip_sync( WorkspaceCache::default(), concurrency, preview, + Preferences::default(), ); // Determine the set of installed packages. diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 2b7938da6193d..509ac8f753ce4 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::collections::hash_map::Entry; use std::fmt::Write; use std::io; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; @@ -36,7 +36,7 @@ use uv_pypi_types::{ParsedUrl, VerbatimParsedUrl}; use uv_python::{Interpreter, PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; use uv_redacted::DisplaySafeUrl; use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification}; -use uv_resolver::FlatIndex; +use uv_resolver::{FlatIndex, Preference, Preferences, ResolverEnvironment}; use uv_scripts::{Pep723ItemRef, Pep723Metadata, Pep723Script}; use uv_settings::PythonInstallMirrors; use uv_types::{BuildIsolation, HashStrategy}; @@ -427,6 +427,33 @@ pub(crate) async fn add( FlatIndex::from_entries(entries, None, &hasher, &settings.resolver.build_options) }; + // Load preferences from the existing lockfile if available. + let preferences = if let Ok(Some(lock)) = LockTarget::from(&target).read().await { + Preferences::from_iter( + lock.packages() + .iter() + .filter_map(|package| { + Preference::from_lock( + package, + match &target { + AddTarget::Script(_, _) => Path::new(".") + .canonicalize() + .unwrap_or_else(|_| PathBuf::from(".")), + AddTarget::Project(project, _) => { + project.workspace().install_path().clone() + } + } + .as_path(), + ) + .transpose() + }) + .collect::, _>>()?, + &ResolverEnvironment::specific(target.interpreter().markers().clone().into()), + ) + } else { + Preferences::default() + }; + // Create a build dispatch. let build_dispatch = BuildDispatch::new( &client, @@ -450,6 +477,7 @@ pub(crate) async fn add( WorkspaceCache::default(), concurrency, preview, + preferences, ); requirements.extend( diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 8eb0d48690714..86a3e70146835 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -29,7 +29,7 @@ use uv_python::{Interpreter, PythonDownloads, PythonEnvironment, PythonPreferenc use uv_requirements::ExtrasResolver; use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements}; use uv_resolver::{ - FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement, + FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, Preferences, PythonRequirement, ResolverEnvironment, ResolverManifest, SatisfiesResult, UniversalMarker, }; use uv_scripts::{Pep723ItemRef, Pep723Script}; @@ -663,6 +663,25 @@ async fn do_lock( FlatIndex::from_entries(entries, None, &hasher, build_options) }; + // We extract preferences before validation because validation may need to build source + // distributions to get their metadata. + let preferences = existing_lock + .as_ref() + .map(|existing_lock| -> Result { + let locked = read_lock_requirements(existing_lock, target.install_path(), upgrade)?; + // Populate the Git resolver. + for ResolvedRepositoryReference { reference, sha } in &locked.git { + debug!("Inserting Git reference into resolver: `{reference:?}` at `{sha}`"); + state.git().insert(reference.clone(), *sha); + } + Ok(Preferences::from_iter( + locked.preferences, + &ResolverEnvironment::universal(vec![]), + )) + }) + .transpose()? + .unwrap_or_default(); + // Create a build dispatch. let build_dispatch = BuildDispatch::new( &client, @@ -685,6 +704,7 @@ async fn do_lock( workspace_cache.clone(), concurrency, preview, + preferences, ); let database = DistributionDatabase::new(&client, &build_dispatch, concurrency.downloads); diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index a5953cd761fbb..5051fe8681815 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -36,8 +36,8 @@ use uv_python::{ use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements}; use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification}; use uv_resolver::{ - FlatIndex, Lock, OptionsBuilder, Preference, PythonRequirement, ResolverEnvironment, - ResolverOutput, + FlatIndex, Lock, OptionsBuilder, Preference, Preferences, PythonRequirement, + ResolverEnvironment, ResolverOutput, }; use uv_scripts::Pep723ItemRef; use uv_settings::PythonInstallMirrors; @@ -1761,6 +1761,7 @@ pub(crate) async fn resolve_names( workspace_cache.clone(), concurrency, preview, + Preferences::default(), ); // Resolve the unnamed requirements. @@ -1969,6 +1970,7 @@ pub(crate) async fn resolve_environment( workspace_cache, concurrency, preview, + Preferences::default(), ); // Resolve the requirements. @@ -2107,6 +2109,7 @@ pub(crate) async fn sync_environment( workspace_cache, concurrency, preview, + Preferences::default(), ); // Sync the environment. @@ -2331,6 +2334,7 @@ pub(crate) async fn update_environment( workspace_cache, concurrency, preview, + Preferences::default(), ); // Resolve the requirements. diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 7d1b0f3cee70b..500f582756102 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -26,7 +26,7 @@ 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_resolver::{FlatIndex, Installable, Lock, Preference, Preferences, ResolverEnvironment}; use uv_scripts::{Pep723ItemRef, Pep723Script}; use uv_settings::PythonInstallMirrors; use uv_types::{BuildIsolation, HashStrategy}; @@ -700,6 +700,17 @@ pub(super) async fn do_sync( FlatIndex::from_entries(entries, Some(&tags), &hasher, build_options) }; + // Extract preferences from the lockfile. + let preferences = Preferences::from_iter( + target + .lock() + .packages() + .iter() + .filter_map(|package| Preference::from_lock(package, target.install_path()).transpose()) + .collect::, _>>()?, + &ResolverEnvironment::specific(marker_env.clone()), + ); + // Create a build dispatch. let build_dispatch = BuildDispatch::new( &client, @@ -722,6 +733,7 @@ pub(super) async fn do_sync( workspace_cache.clone(), concurrency, preview, + preferences, ); let site_packages = SitePackages::from_environment(venv)?; diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 1f2ce3dfbf57b..14225bd993af3 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -23,7 +23,7 @@ use uv_normalize::DefaultGroups; use uv_python::{ EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest, }; -use uv_resolver::{ExcludeNewer, FlatIndex}; +use uv_resolver::{ExcludeNewer, FlatIndex, Preferences}; use uv_settings::PythonInstallMirrors; use uv_shell::{Shell, shlex_posix, shlex_windows}; use uv_types::{AnyErrorBuild, BuildContext, BuildIsolation, BuildStack, HashStrategy}; @@ -289,6 +289,7 @@ pub(crate) async fn venv( workspace_cache, concurrency, preview, + Preferences::default(), ); // Resolve the seed packages. diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 7fa46f0c32400..ccab4b5403c6d 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -13595,3 +13595,151 @@ fn add_path_outside_workspace_no_default() -> Result<()> { Ok(()) } + +/// Test that `uv add` respects lockfile preferences for build dependencies. +#[test] +fn add_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"] + "#})?; + + // Create a lockfile with anyio 4.0.0 + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + "); + + // Ensure our build backend is checking the version correctly with a wrong version + uv_snapshot!(context.filters(), context.add().arg("./child").env("EXPECTED_ANYIO_VERSION", "3.0"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Added `child` to workspace members + Resolved [N] packages in [TIME] + × Failed to build `child @ file://[TEMP_DIR]/child` + ├─▶ The build backend returned an error + ╰─▶ Call to `build_backend.build_editable` failed (exit status: 1) + + [stderr] + Expected `anyio` version 3.0 but got 4.0.0 + + hint: This usually indicates a problem with the package or the build environment. + help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing. + "); + + // The child should be built with anyio 4.0 + uv_snapshot!(context.filters(), context.add().arg("./child").env("EXPECTED_ANYIO_VERSION", "4.0"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Added `child` to workspace members + 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 + "); + + // Check that child was added correctly + let pyproject_content = fs_err::read_to_string(pyproject_toml.path())?; + assert!(pyproject_content.contains("dependencies = [")); + assert!(pyproject_content.contains("child")); + + // 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 = { workspace = true } + + [tool.uv.workspace] + members = ["child"] + "#})?; + + // The child should be rebuilt with anyio 3.7 on sync with reinstall + 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 ----- + 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) + "); + + Ok(()) +} diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 49951b42bbf58..5b43c7c6feb3b 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -11386,3 +11386,152 @@ 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.0.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` + "); + + // 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 ----- + 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" } + "#})?; + + // The child should be rebuilt with anyio 3.7 on reinstall + 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 ----- + 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) + "); + + Ok(()) +} From bae72a8c10be0f3bf0daa6c7360ae09f6a021648 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 28 Jul 2025 15:45:51 -0500 Subject: [PATCH 2/7] Add a setting --- crates/uv-cli/src/options.rs | 2 + crates/uv-configuration/src/lib.rs | 2 + crates/uv-settings/src/combine.rs | 5 +- crates/uv-settings/src/lib.rs | 1 + crates/uv-settings/src/settings.rs | 28 ++++++++++- crates/uv/src/commands/build_frontend.rs | 1 + crates/uv/src/commands/project/add.rs | 64 +++++++++++++----------- crates/uv/src/commands/project/lock.rs | 40 ++++++++------- crates/uv/src/commands/project/mod.rs | 3 ++ crates/uv/src/commands/project/tree.rs | 1 + crates/uv/src/settings.rs | 14 ++++-- crates/uv/tests/it/edit.rs | 6 +++ crates/uv/tests/it/sync.rs | 8 ++- docs/reference/settings.md | 34 +++++++++++++ uv.schema.json | 25 +++++++++ 15 files changed, 175 insertions(+), 59 deletions(-) diff --git a/crates/uv-cli/src/options.rs b/crates/uv-cli/src/options.rs index d2e651a190151..ce3d8621a9456 100644 --- a/crates/uv-cli/src/options.rs +++ b/crates/uv-cli/src/options.rs @@ -354,6 +354,7 @@ pub fn resolver_options( no_binary: flag(no_binary, binary, "binary"), no_binary_package: Some(no_binary_package), no_sources: if no_sources { Some(true) } else { None }, + build_dependency_strategy: None, } } @@ -480,5 +481,6 @@ pub fn resolver_installer_options( Some(no_binary_package) }, no_sources: if no_sources { Some(true) } else { None }, + build_dependency_strategy: None, } } diff --git a/crates/uv-configuration/src/lib.rs b/crates/uv-configuration/src/lib.rs index ffc15c2d3ebad..158c64255e2da 100644 --- a/crates/uv-configuration/src/lib.rs +++ b/crates/uv-configuration/src/lib.rs @@ -1,4 +1,5 @@ pub use authentication::*; +pub use build_dependency_strategy::*; pub use build_options::*; pub use concurrency::*; pub use config_settings::*; @@ -24,6 +25,7 @@ pub use trusted_publishing::*; pub use vcs::*; mod authentication; +mod build_dependency_strategy; mod build_options; mod concurrency; mod config_settings; diff --git a/crates/uv-settings/src/combine.rs b/crates/uv-settings/src/combine.rs index 738b00ffe1789..b8d2e3c284346 100644 --- a/crates/uv-settings/src/combine.rs +++ b/crates/uv-settings/src/combine.rs @@ -4,8 +4,8 @@ use std::path::PathBuf; use url::Url; use uv_configuration::{ - ConfigSettings, ExportFormat, IndexStrategy, KeyringProviderType, PackageConfigSettings, - RequiredVersion, TargetTriple, TrustedPublishing, + BuildDependencyStrategy, ConfigSettings, ExportFormat, IndexStrategy, KeyringProviderType, + PackageConfigSettings, RequiredVersion, TargetTriple, TrustedPublishing, }; use uv_distribution_types::{Index, IndexUrl, PipExtraIndex, PipFindLinks, PipIndex}; use uv_install_wheel::LinkMode; @@ -77,6 +77,7 @@ macro_rules! impl_combine_or { impl_combine_or!(AddBoundsKind); impl_combine_or!(AnnotationStyle); +impl_combine_or!(BuildDependencyStrategy); impl_combine_or!(ExcludeNewer); impl_combine_or!(ExportFormat); impl_combine_or!(ForkStrategy); diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index 0e3f13f7167c9..d56d512e00fad 100644 --- a/crates/uv-settings/src/lib.rs +++ b/crates/uv-settings/src/lib.rs @@ -329,6 +329,7 @@ fn warn_uv_toml_masked_fields(options: &Options) { no_build_package, no_binary, no_binary_package, + build_dependency_strategy: _, }, install_mirrors: PythonInstallMirrors { diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 9eb765a1ed2eb..830438cada277 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -4,8 +4,9 @@ use serde::{Deserialize, Serialize}; use uv_cache_info::CacheKey; use uv_configuration::{ - ConfigSettings, IndexStrategy, KeyringProviderType, PackageConfigSettings, - PackageNameSpecifier, RequiredVersion, TargetTriple, TrustedHost, TrustedPublishing, + BuildDependencyStrategy, ConfigSettings, IndexStrategy, KeyringProviderType, + PackageConfigSettings, PackageNameSpecifier, RequiredVersion, TargetTriple, TrustedHost, + TrustedPublishing, }; use uv_distribution_types::{ Index, IndexUrl, IndexUrlError, PipExtraIndex, PipFindLinks, PipIndex, StaticMetadata, @@ -373,6 +374,7 @@ pub struct ResolverOptions { pub no_build_isolation: Option, pub no_build_isolation_package: Option>, pub no_sources: Option, + pub build_dependency_strategy: Option, } /// Shared settings, relevant to all operations that must resolve and install dependencies. The @@ -509,6 +511,23 @@ pub struct ResolverInstallerOptions { "# )] pub keyring_provider: Option, + /// The strategy to use when resolving build dependencies for source distributions. + /// + /// - `latest`: Use the latest compatible version of each build dependency. + /// - `prefer-locked`: Prefer the versions pinned in the lockfile, if available. + /// + /// When set to `prefer-locked`, uv will use the locked versions of packages specified in the + /// lockfile as preferences when resolving build dependencies during source builds. This helps + /// ensure that build environments are consistent with the project's resolved dependencies. + #[option( + default = "\"latest\"", + value_type = "str", + example = r#" + build-dependency-strategy = "prefer-locked" + "#, + possible_values = true + )] + pub build_dependency_strategy: Option, /// The strategy to use when selecting between the different compatible versions for a given /// package requirement. /// @@ -1686,6 +1705,7 @@ impl From for ResolverOptions { no_build_isolation: value.no_build_isolation, no_build_isolation_package: value.no_build_isolation_package, no_sources: value.no_sources, + build_dependency_strategy: value.build_dependency_strategy, } } } @@ -1811,6 +1831,7 @@ impl From for ResolverInstallerOptions { no_build_package: value.no_build_package, no_binary: value.no_binary, no_binary_package: value.no_binary_package, + build_dependency_strategy: None, } } } @@ -1864,6 +1885,7 @@ pub struct OptionsWire { no_build_package: Option>, no_binary: Option, no_binary_package: Option>, + build_dependency_strategy: Option, // #[serde(flatten)] // install_mirror: PythonInstallMirrors, @@ -1954,6 +1976,7 @@ impl From for Options { no_build_package, no_binary, no_binary_package, + build_dependency_strategy, pip, cache_keys, override_dependencies, @@ -2021,6 +2044,7 @@ impl From for Options { no_build_package, no_binary, no_binary_package, + build_dependency_strategy, }, pip, cache_keys, diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index b613cf30e6644..c817bc3e57854 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -205,6 +205,7 @@ async fn build_impl( upgrade: _, build_options, sources, + build_dependency_strategy: _, } = settings; let client_builder = BaseClientBuilder::default() diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 509ac8f753ce4..7ccb4876acf41 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::collections::hash_map::Entry; use std::fmt::Write; use std::io; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::str::FromStr; use std::sync::Arc; @@ -17,9 +17,9 @@ use uv_cache::Cache; use uv_cache_key::RepositoryUrl; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - Concurrency, Constraints, DependencyGroups, DependencyGroupsWithDefaults, DevMode, DryRun, - EditableMode, ExtrasSpecification, ExtrasSpecificationWithDefaults, InstallOptions, Preview, - PreviewFeatures, SourceStrategy, + BuildDependencyStrategy, Concurrency, Constraints, DependencyGroups, + DependencyGroupsWithDefaults, DevMode, DryRun, EditableMode, ExtrasSpecification, + ExtrasSpecificationWithDefaults, InstallOptions, Preview, PreviewFeatures, SourceStrategy, }; use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; @@ -27,7 +27,7 @@ use uv_distribution_types::{ Index, IndexName, IndexUrl, IndexUrls, NameRequirementSpecification, Requirement, RequirementSource, UnresolvedRequirement, VersionId, }; -use uv_fs::{LockedFile, Simplified}; +use uv_fs::{CWD, LockedFile, Simplified}; use uv_git::GIT_STORE; use uv_git_types::GitReference; use uv_normalize::{DEV_DEPENDENCIES, DefaultExtras, DefaultGroups, PackageName}; @@ -427,31 +427,27 @@ pub(crate) async fn add( FlatIndex::from_entries(entries, None, &hasher, &settings.resolver.build_options) }; - // Load preferences from the existing lockfile if available. - let preferences = if let Ok(Some(lock)) = LockTarget::from(&target).read().await { - Preferences::from_iter( - lock.packages() - .iter() - .filter_map(|package| { - Preference::from_lock( - package, - match &target { - AddTarget::Script(_, _) => Path::new(".") - .canonicalize() - .unwrap_or_else(|_| PathBuf::from(".")), - AddTarget::Project(project, _) => { - project.workspace().install_path().clone() - } - } - .as_path(), - ) - .transpose() - }) - .collect::, _>>()?, - &ResolverEnvironment::specific(target.interpreter().markers().clone().into()), - ) - } else { - Preferences::default() + // Load preferences from the existing lockfile if available and if configured to do so. + let preferences = match settings.resolver.build_dependency_strategy { + BuildDependencyStrategy::PreferLocked => { + if let Ok(Some(lock)) = LockTarget::from(&target).read().await { + Preferences::from_iter( + lock.packages() + .iter() + .filter_map(|package| { + Preference::from_lock(package, &target.install_path()) + .transpose() + }) + .collect::, _>>()?, + &ResolverEnvironment::specific( + target.interpreter().markers().clone().into(), + ), + ) + } else { + Preferences::default() + } + } + BuildDependencyStrategy::Latest => Preferences::default(), }; // Create a build dispatch. @@ -1345,6 +1341,14 @@ impl AddTarget { } } + /// Return the parent path of the target. + pub(crate) fn install_path(&self) -> &Path { + match self { + Self::Script(script, _) => script.path.parent().unwrap_or(&*CWD), + Self::Project(project, _) => project.root(), + } + } + /// Write the updated content to the target. /// /// Returns `true` if the content was modified. diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 86a3e70146835..1754eb6ff1f4f 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -12,8 +12,8 @@ use tracing::debug; use uv_cache::Cache; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - Concurrency, Constraints, DependencyGroupsWithDefaults, DryRun, ExtrasSpecification, Preview, - Reinstall, Upgrade, + BuildDependencyStrategy, Concurrency, Constraints, DependencyGroupsWithDefaults, DryRun, + ExtrasSpecification, Preview, Reinstall, Upgrade, }; use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; @@ -440,6 +440,7 @@ async fn do_lock( upgrade, build_options, sources, + build_dependency_strategy, } = settings; // Collect the requirements, etc. @@ -663,24 +664,25 @@ async fn do_lock( FlatIndex::from_entries(entries, None, &hasher, build_options) }; + // Extract preferences and git refs from the existing lockfile if available for build dispatch. // We extract preferences before validation because validation may need to build source - // distributions to get their metadata. - let preferences = existing_lock - .as_ref() - .map(|existing_lock| -> Result { - let locked = read_lock_requirements(existing_lock, target.install_path(), upgrade)?; - // Populate the Git resolver. - for ResolvedRepositoryReference { reference, sha } in &locked.git { - debug!("Inserting Git reference into resolver: `{reference:?}` at `{sha}`"); - state.git().insert(reference.clone(), *sha); - } - Ok(Preferences::from_iter( - locked.preferences, - &ResolverEnvironment::universal(vec![]), - )) - }) - .transpose()? - .unwrap_or_default(); + // distributions to get their metadata, and those builds should use the lockfile's preferences + // for accuracy. While the lockfile hasn't been validated yet, using its preferences is still + // better than using defaults, as most lockfiles are valid and this gives more accurate results. + let preferences = match build_dependency_strategy { + BuildDependencyStrategy::PreferLocked => existing_lock + .as_ref() + .map(|existing_lock| -> Result { + Ok(Preferences::from_iter( + read_lock_requirements(existing_lock, target.install_path(), upgrade)? + .preferences, + &ResolverEnvironment::universal(vec![]), + )) + }) + .transpose()? + .unwrap_or_default(), + BuildDependencyStrategy::Latest => Preferences::default(), + }; // Create a build dispatch. let build_dispatch = BuildDispatch::new( diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 5051fe8681815..d39d360cb316b 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1696,6 +1696,7 @@ pub(crate) async fn resolve_names( resolution: _, sources, upgrade: _, + build_dependency_strategy: _, }, compile_bytecode: _, reinstall: _, @@ -1851,6 +1852,7 @@ pub(crate) async fn resolve_environment( upgrade: _, build_options, sources, + build_dependency_strategy: _, } = settings; // Respect all requirements from the provided sources. @@ -2201,6 +2203,7 @@ pub(crate) async fn update_environment( resolution, sources, upgrade, + build_dependency_strategy: _, }, compile_bytecode, reinstall, diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 1d594bd53907c..521bf188e9e50 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -208,6 +208,7 @@ pub(crate) async fn tree( upgrade: _, build_options: _, sources: _, + build_dependency_strategy: _, } = &settings; let capabilities = IndexCapabilities::default(); diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index b563b0b8e586c..121bfba941497 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -21,11 +21,11 @@ use uv_cli::{ }; use uv_client::Connectivity; use uv_configuration::{ - BuildOptions, Concurrency, ConfigSettings, DependencyGroups, DryRun, EditableMode, - ExportFormat, ExtrasSpecification, HashCheckingMode, IndexStrategy, InstallOptions, - KeyringProviderType, NoBinary, NoBuild, PackageConfigSettings, Preview, ProjectBuildBackend, - Reinstall, RequiredVersion, SourceStrategy, TargetTriple, TrustedHost, TrustedPublishing, - Upgrade, VersionControlSystem, + BuildDependencyStrategy, BuildOptions, Concurrency, ConfigSettings, DependencyGroups, DryRun, + EditableMode, ExportFormat, ExtrasSpecification, HashCheckingMode, IndexStrategy, + InstallOptions, KeyringProviderType, NoBinary, NoBuild, PackageConfigSettings, Preview, + ProjectBuildBackend, Reinstall, RequiredVersion, SourceStrategy, TargetTriple, TrustedHost, + TrustedPublishing, Upgrade, VersionControlSystem, }; use uv_distribution_types::{DependencyMetadata, Index, IndexLocations, IndexUrl, Requirement}; use uv_install_wheel::LinkMode; @@ -2738,6 +2738,7 @@ pub(crate) struct ResolverSettings { pub(crate) resolution: ResolutionMode, pub(crate) sources: SourceStrategy, pub(crate) upgrade: Upgrade, + pub(crate) build_dependency_strategy: BuildDependencyStrategy, } impl ResolverSettings { @@ -2802,6 +2803,7 @@ impl From for ResolverSettings { NoBinary::from_args(value.no_binary, value.no_binary_package.unwrap_or_default()), NoBuild::from_args(value.no_build, value.no_build_package.unwrap_or_default()), ), + build_dependency_strategy: value.build_dependency_strategy.unwrap_or_default(), } } } @@ -2887,6 +2889,7 @@ impl From for ResolverInstallerSettings { .map(Requirement::from) .collect(), ), + build_dependency_strategy: value.build_dependency_strategy.unwrap_or_default(), }, compile_bytecode: value.compile_bytecode.unwrap_or_default(), reinstall: Reinstall::from_args( @@ -3054,6 +3057,7 @@ impl PipSettings { no_build_package: top_level_no_build_package, no_binary: top_level_no_binary, no_binary_package: top_level_no_binary_package, + build_dependency_strategy: _, } = top_level; // Merge the top-level options (`tool.uv`) with the pip-specific options (`tool.uv.pip`), diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index ccab4b5403c6d..cc8521ce31a38 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -13655,6 +13655,9 @@ fn add_build_dependencies_respect_locked_versions() -> Result<()> { version = "0.1.0" requires-python = ">=3.9" dependencies = ["anyio<4.1"] + + [tool.uv] + build-dependency-strategy = "prefer-locked" "#})?; // Create a lockfile with anyio 4.0.0 @@ -13717,6 +13720,9 @@ fn add_build_dependencies_respect_locked_versions() -> Result<()> { requires-python = ">=3.9" dependencies = ["anyio<3.8", "child"] + [tool.uv] + build-dependency-strategy = "prefer-locked" + [tool.uv.sources] child = { workspace = true } diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 5b43c7c6feb3b..125bb6d97913e 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -11457,7 +11457,7 @@ fn sync_build_dependencies_respect_locked_versions() -> Result<()> { Resolved [N] packages in [TIME] "); - // Now add the child dependency + // Now add the child dependency with build-dependency-strategy = "prefer-locked" pyproject_toml.write_str(indoc! {r#" [project] name = "parent" @@ -11465,6 +11465,9 @@ fn sync_build_dependencies_respect_locked_versions() -> Result<()> { requires-python = ">=3.9" dependencies = ["anyio<4.1", "child"] + [tool.uv] + build-dependency-strategy = "prefer-locked" + [tool.uv.sources] child = { path = "child" } "#})?; @@ -11512,6 +11515,9 @@ fn sync_build_dependencies_respect_locked_versions() -> Result<()> { requires-python = ">=3.9" dependencies = ["anyio<3.8", "child"] + [tool.uv] + build-dependency-strategy = "prefer-locked" + [tool.uv.sources] child = { path = "child" } "#})?; diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 6469a584ba4e4..31dd1157b59d6 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -749,6 +749,40 @@ bypasses SSL verification and could expose you to MITM attacks. --- +### [`build-dependency-strategy`](#build-dependency-strategy) {: #build-dependency-strategy } + +The strategy to use when resolving build dependencies for source distributions. + +- `latest`: Use the latest compatible version of each build dependency. +- `prefer-locked`: Prefer the versions pinned in the lockfile, if available. + +When set to `prefer-locked`, uv will use the locked versions of packages specified in the +lockfile as preferences when resolving build dependencies during source builds. This helps +ensure that build environments are consistent with the project's resolved dependencies. + +**Default value**: `"latest"` + +**Possible values**: + +- `"latest"`: Use the latest compatible version of each build dependency +- `"prefer-locked"`: Prefer the versions pinned in the lockfile, if available + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.uv] + build-dependency-strategy = "prefer-locked" + ``` +=== "uv.toml" + + ```toml + build-dependency-strategy = "prefer-locked" + ``` + +--- + ### [`cache-dir`](#cache-dir) {: #cache-dir } Path to the cache directory. diff --git a/uv.schema.json b/uv.schema.json index 7ca04d4f89b18..d592eca73e682 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -46,6 +46,17 @@ "type": "string" } }, + "build-dependency-strategy": { + "description": "The strategy to use when resolving build dependencies for source distributions.\n\n- `latest`: Use the latest compatible version of each build dependency.\n- `prefer-locked`: Prefer the versions pinned in the lockfile, if available.\n\nWhen set to `prefer-locked`, uv will use the locked versions of packages specified in the\nlockfile as preferences when resolving build dependencies during source builds. This helps\nensure that build environments are consistent with the project's resolved dependencies.", + "anyOf": [ + { + "$ref": "#/definitions/BuildDependencyStrategy" + }, + { + "type": "null" + } + ] + }, "cache-dir": { "description": "Path to the cache directory.\n\nDefaults to `$XDG_CACHE_HOME/uv` or `$HOME/.cache/uv` on Linux and macOS, and\n`%LOCALAPPDATA%\\uv\\cache` on Windows.", "type": [ @@ -726,6 +737,20 @@ } } }, + "BuildDependencyStrategy": { + "oneOf": [ + { + "description": "Use the latest compatible version of each build dependency.", + "type": "string", + "const": "latest" + }, + { + "description": "Prefer the versions pinned in the lockfile, if available.", + "type": "string", + "const": "prefer-locked" + } + ] + }, "CacheKey": { "anyOf": [ { From 0a62cb22c10abee9254817a2204bdb187c7d5b44 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 28 Jul 2025 16:22:46 -0500 Subject: [PATCH 3/7] Add preview gating; propagate setting to `do_sync`; add more test coverage --- crates/uv-configuration/src/preview.rs | 7 ++ crates/uv-settings/src/settings.rs | 2 +- crates/uv/src/commands/project/add.rs | 8 ++- crates/uv/src/commands/project/lock.rs | 12 +++- crates/uv/src/commands/project/mod.rs | 1 + crates/uv/src/commands/project/sync.rs | 42 ++++++++---- crates/uv/src/settings.rs | 2 + crates/uv/tests/it/edit.rs | 33 ++++++++++ crates/uv/tests/it/sync.rs | 89 +++++++++++++++++++++++++- 9 files changed, 178 insertions(+), 18 deletions(-) diff --git a/crates/uv-configuration/src/preview.rs b/crates/uv-configuration/src/preview.rs index c8d67be5bf424..a3e2f4ee2a298 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 PREFER_LOCKED_BUILDS = 1 << 5; } } @@ -28,6 +29,7 @@ impl PreviewFeatures { Self::JSON_OUTPUT => "json-output", Self::PYLOCK => "pylock", Self::ADD_BOUNDS => "add-bounds", + Self::PREFER_LOCKED_BUILDS => "prefer-locked-builds", _ => 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, + "prefer-locked-builds" => Self::PREFER_LOCKED_BUILDS, _ => { 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::PREFER_LOCKED_BUILDS.flag_as_str(), + "prefer-locked-builds" + ); } #[test] diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 830438cada277..254cb204b4122 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -2024,6 +2024,7 @@ impl From for Options { find_links, index_strategy, keyring_provider, + build_dependency_strategy, resolution, prerelease, fork_strategy, @@ -2044,7 +2045,6 @@ impl From for Options { no_build_package, no_binary, no_binary_package, - build_dependency_strategy, }, pip, cache_keys, diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 7ccb4876acf41..a66d2b5173957 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -430,12 +430,18 @@ pub(crate) async fn add( // Load preferences from the existing lockfile if available and if configured to do so. let preferences = match settings.resolver.build_dependency_strategy { BuildDependencyStrategy::PreferLocked => { + if !preview.is_enabled(PreviewFeatures::PREFER_LOCKED_BUILDS) { + warn_user_once!( + "The `build-dependency-strategy` setting is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::PREFER_LOCKED_BUILDS + ); + } if let Ok(Some(lock)) = LockTarget::from(&target).read().await { Preferences::from_iter( lock.packages() .iter() .filter_map(|package| { - Preference::from_lock(package, &target.install_path()) + Preference::from_lock(package, target.install_path()) .transpose() }) .collect::, _>>()?, diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 1754eb6ff1f4f..e4848daba43d7 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::{ BuildDependencyStrategy, Concurrency, Constraints, DependencyGroupsWithDefaults, DryRun, - ExtrasSpecification, Preview, Reinstall, Upgrade, + ExtrasSpecification, Preview, PreviewFeatures, Reinstall, Upgrade, }; use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; @@ -443,6 +443,16 @@ async fn do_lock( build_dependency_strategy, } = settings; + // Warn if using build-dependency-strategy without preview + if *build_dependency_strategy == BuildDependencyStrategy::PreferLocked + && !preview.is_enabled(PreviewFeatures::PREFER_LOCKED_BUILDS) + { + warn_user_once!( + "The `build-dependency-strategy` setting is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::PREFER_LOCKED_BUILDS + ); + } + // Collect the requirements, etc. let members = target.members(); let packages = target.packages(); diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index d39d360cb316b..2579030ee27d2 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -2038,6 +2038,7 @@ pub(crate) async fn sync_environment( reinstall, build_options, sources, + build_dependency_strategy: _, } = settings; let client_builder = BaseClientBuilder::new() diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 500f582756102..9794c109e83b7 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -12,9 +12,10 @@ use uv_cache::Cache; use uv_cli::SyncFormat; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - Concurrency, Constraints, DependencyGroups, DependencyGroupsWithDefaults, DryRun, EditableMode, - ExtrasSpecification, ExtrasSpecificationWithDefaults, HashCheckingMode, InstallOptions, - Preview, PreviewFeatures, TargetTriple, + BuildDependencyStrategy, Concurrency, Constraints, DependencyGroups, + DependencyGroupsWithDefaults, DryRun, EditableMode, ExtrasSpecification, + ExtrasSpecificationWithDefaults, HashCheckingMode, InstallOptions, Preview, PreviewFeatures, + TargetTriple, }; use uv_dispatch::BuildDispatch; use uv_distribution_types::{ @@ -30,7 +31,7 @@ use uv_resolver::{FlatIndex, Installable, Lock, Preference, Preferences, Resolve use uv_scripts::{Pep723ItemRef, 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}; @@ -584,6 +585,7 @@ pub(super) async fn do_sync( reinstall, build_options, sources, + build_dependency_strategy, } = settings; let client_builder = BaseClientBuilder::new() @@ -700,16 +702,28 @@ pub(super) async fn do_sync( FlatIndex::from_entries(entries, Some(&tags), &hasher, build_options) }; - // Extract preferences from the lockfile. - let preferences = Preferences::from_iter( - target - .lock() - .packages() - .iter() - .filter_map(|package| Preference::from_lock(package, target.install_path()).transpose()) - .collect::, _>>()?, - &ResolverEnvironment::specific(marker_env.clone()), - ); + let preferences = match build_dependency_strategy { + BuildDependencyStrategy::PreferLocked => { + if !preview.is_enabled(PreviewFeatures::PREFER_LOCKED_BUILDS) { + warn_user_once!( + "The `build-dependency-strategy` setting is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::PREFER_LOCKED_BUILDS + ); + } + Preferences::from_iter( + target + .lock() + .packages() + .iter() + .filter_map(|package| { + Preference::from_lock(package, target.install_path()).transpose() + }) + .collect::, _>>()?, + &ResolverEnvironment::specific(marker_env.clone()), + ) + } + BuildDependencyStrategy::Latest => Preferences::default(), + }; // Create a build dispatch. let build_dispatch = BuildDispatch::new( diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 121bfba941497..8036f3c285de7 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -2714,6 +2714,7 @@ pub(crate) struct InstallerSettingsRef<'a> { pub(crate) reinstall: &'a Reinstall, pub(crate) build_options: &'a BuildOptions, pub(crate) sources: SourceStrategy, + pub(crate) build_dependency_strategy: &'a BuildDependencyStrategy, } /// The resolved settings to use for an invocation of the uv CLI when resolving dependencies. @@ -3286,6 +3287,7 @@ impl<'a> From<&'a ResolverInstallerSettings> for InstallerSettingsRef<'a> { reinstall: &settings.reinstall, build_options: &settings.resolver.build_options, sources: settings.resolver.sources, + build_dependency_strategy: &settings.resolver.build_dependency_strategy, } } } diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index cc8521ce31a38..03360edf702d9 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -13747,5 +13747,38 @@ fn add_build_dependencies_respect_locked_versions() -> Result<()> { ~ child==0.1.0 (from file://[TEMP_DIR]/child) "); + // Test with build-dependency-strategy = "latest" to show different behavior + pyproject_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = ["anyio<3.8", "child", "typing-extensions"] + + [tool.uv] + build-dependency-strategy = "latest" + + [tool.uv.sources] + child = { workspace = true } + + [tool.uv.workspace] + members = ["child"] + "#})?; + + // With latest strategy, when we sync it should use anyio 4.x for builds + uv_snapshot!(context.filters(), context.sync() + .arg("--reinstall-package").arg("child") + .env("EXPECTED_ANYIO_VERSION", "4."), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + typing-extensions==4.12.2 + "); + Ok(()) } diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 125bb6d97913e..3bade6dea7758 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -11457,7 +11457,7 @@ fn sync_build_dependencies_respect_locked_versions() -> Result<()> { Resolved [N] packages in [TIME] "); - // Now add the child dependency with build-dependency-strategy = "prefer-locked" + // Now add the child dependency with `build-dependency-strategy = "prefer-locked"` pyproject_toml.write_str(indoc! {r#" [project] name = "parent" @@ -11479,6 +11479,7 @@ fn sync_build_dependencies_respect_locked_versions() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: The `build-dependency-strategy` setting is experimental and may change without warning. Pass `--preview-features prefer-locked-builds` to disable this warning. Resolved [N] packages in [TIME] × Failed to build `child @ file://[TEMP_DIR]/child` ├─▶ The build backend returned an error @@ -11498,6 +11499,7 @@ fn sync_build_dependencies_respect_locked_versions() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: The `build-dependency-strategy` setting is experimental and may change without warning. Pass `--preview-features prefer-locked-builds` to disable this warning. Resolved [N] packages in [TIME] Prepared [N] packages in [TIME] Installed [N] packages in [TIME] @@ -11530,6 +11532,7 @@ fn sync_build_dependencies_respect_locked_versions() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: The `build-dependency-strategy` setting is experimental and may change without warning. Pass `--preview-features prefer-locked-builds` to disable this warning. Resolved [N] packages in [TIME] Prepared [N] packages in [TIME] Uninstalled [N] packages in [TIME] @@ -11539,5 +11542,89 @@ fn sync_build_dependencies_respect_locked_versions() -> Result<()> { ~ 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("prefer-locked-builds") + .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 test with build-dependency-strategy = "latest" to show it uses latest 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] + build-dependency-strategy = "latest" + + [tool.uv.sources] + child = { path = "child" } + "#})?; + + // With latest strategy, it should use the latest compatible anyio (4.3) for builds + // even though the project requires anyio<3.8 + uv_snapshot!(context.filters(), context.sync() + .arg("--reinstall-package").arg("child") + .env("EXPECTED_ANYIO_VERSION", "4.3"), @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) + "); + + // Similarly, `prefer-locked` without a dependency on `anyio` should still use the latest + // anyio version + pyproject_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = ["child"] + + [tool.uv] + build-dependency-strategy = "prefer-locked" + + [tool.uv.sources] + child = { path = "child" } + "#})?; + + uv_snapshot!(context.filters(), context.sync() + .arg("--reinstall-package").arg("child") + .env("EXPECTED_ANYIO_VERSION", "4.3"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: The `build-dependency-strategy` setting is experimental and may change without warning. Pass `--preview-features prefer-locked-builds` 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==3.7.1 + ~ child==0.1.0 (from file://[TEMP_DIR]/child) + - idna==3.6 + - sniffio==1.3.1 + "); + Ok(()) } From 5deea0fa8a86e332c38f0f1a65264fff7392e9d1 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 28 Jul 2025 16:30:14 -0500 Subject: [PATCH 4/7] Oopsie, commit new file --- .../src/build_dependency_strategy.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 crates/uv-configuration/src/build_dependency_strategy.rs diff --git a/crates/uv-configuration/src/build_dependency_strategy.rs b/crates/uv-configuration/src/build_dependency_strategy.rs new file mode 100644 index 0000000000000..28967e414ea85 --- /dev/null +++ b/crates/uv-configuration/src/build_dependency_strategy.rs @@ -0,0 +1,20 @@ +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum BuildDependencyStrategy { + /// Use the latest compatible version of each build dependency. + #[default] + Latest, + /// Prefer the versions pinned in the lockfile, if available. + PreferLocked, +} + +impl std::fmt::Display for BuildDependencyStrategy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Latest => write!(f, "latest"), + Self::PreferLocked => write!(f, "prefer-locked"), + } + } +} From 53bab0bb0afca0d99a353d8ab020eecbe8350d3a Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 28 Jul 2025 16:54:12 -0500 Subject: [PATCH 5/7] Update snapshots --- crates/uv/tests/it/edit.rs | 8 +++++++- crates/uv/tests/it/show_settings.rs | 14 +++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 03360edf702d9..65a9dd02376d2 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -13667,6 +13667,7 @@ fn add_build_dependencies_respect_locked_versions() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: The `build-dependency-strategy` setting is experimental and may change without warning. Pass `--preview-features prefer-locked-builds` to disable this warning. Resolved [N] packages in [TIME] "); @@ -13677,6 +13678,7 @@ fn add_build_dependencies_respect_locked_versions() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: The `build-dependency-strategy` setting is experimental and may change without warning. Pass `--preview-features prefer-locked-builds` to disable this warning. Added `child` to workspace members Resolved [N] packages in [TIME] × Failed to build `child @ file://[TEMP_DIR]/child` @@ -13697,6 +13699,7 @@ fn add_build_dependencies_respect_locked_versions() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: The `build-dependency-strategy` setting is experimental and may change without warning. Pass `--preview-features prefer-locked-builds` to disable this warning. Added `child` to workspace members Resolved [N] packages in [TIME] Prepared [N] packages in [TIME] @@ -13738,6 +13741,7 @@ fn add_build_dependencies_respect_locked_versions() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: The `build-dependency-strategy` setting is experimental and may change without warning. Pass `--preview-features prefer-locked-builds` to disable this warning. Resolved [N] packages in [TIME] Prepared [N] packages in [TIME] Uninstalled [N] packages in [TIME] @@ -13776,8 +13780,10 @@ fn add_build_dependencies_respect_locked_versions() -> Result<()> { ----- stderr ----- Resolved [N] packages in [TIME] Prepared [N] packages in [TIME] + Uninstalled [N] packages in [TIME] Installed [N] packages in [TIME] - + typing-extensions==4.12.2 + ~ child==0.1.0 (from file://[TEMP_DIR]/child) + + typing-extensions==4.10.0 "); Ok(()) diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 65555ba56d393..d0a90780e44e2 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -3317,6 +3317,7 @@ fn resolve_tool() -> anyhow::Result<()> { find_links: None, index_strategy: None, keyring_provider: None, + build_dependency_strategy: None, resolution: Some( LowestDirect, ), @@ -3373,6 +3374,7 @@ fn resolve_tool() -> anyhow::Result<()> { resolution: LowestDirect, sources: Enabled, upgrade: None, + build_dependency_strategy: Latest, }, compile_bytecode: false, reinstall: None, @@ -4384,7 +4386,7 @@ fn resolve_config_file() -> anyhow::Result<()> { | 1 | [project] | ^^^^^^^ - unknown field `project`, expected one of `required-version`, `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `fork-strategy`, `dependency-metadata`, `config-settings`, `config-settings-package`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `python-install-mirror`, `pypy-install-mirror`, `python-downloads-json-url`, `publish-url`, `trusted-publishing`, `check-url`, `add-bounds`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `build-constraint-dependencies`, `environments`, `required-environments`, `conflicts`, `workspace`, `sources`, `managed`, `package`, `default-groups`, `dependency-groups`, `dev-dependencies`, `build-backend` + unknown field `project`, expected one of `required-version`, `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `fork-strategy`, `dependency-metadata`, `config-settings`, `config-settings-package`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `build-dependency-strategy`, `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` " ); @@ -7320,7 +7322,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 | PREFER_LOCKED_BUILDS, ), }, python_preference: Managed, @@ -7390,6 +7392,7 @@ fn preview_features() { resolution: Highest, sources: Enabled, upgrade: None, + build_dependency_strategy: Latest, }, compile_bytecode: false, reinstall: None, @@ -7492,6 +7495,7 @@ fn preview_features() { resolution: Highest, sources: Enabled, upgrade: None, + build_dependency_strategy: Latest, }, compile_bytecode: false, reinstall: None, @@ -7524,7 +7528,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 | PREFER_LOCKED_BUILDS, ), }, python_preference: Managed, @@ -7594,6 +7598,7 @@ fn preview_features() { resolution: Highest, sources: Enabled, upgrade: None, + build_dependency_strategy: Latest, }, compile_bytecode: false, reinstall: None, @@ -7696,6 +7701,7 @@ fn preview_features() { resolution: Highest, sources: Enabled, upgrade: None, + build_dependency_strategy: Latest, }, compile_bytecode: false, reinstall: None, @@ -7798,6 +7804,7 @@ fn preview_features() { resolution: Highest, sources: Enabled, upgrade: None, + build_dependency_strategy: Latest, }, compile_bytecode: false, reinstall: None, @@ -7902,6 +7909,7 @@ fn preview_features() { resolution: Highest, sources: Enabled, upgrade: None, + build_dependency_strategy: Latest, }, compile_bytecode: false, reinstall: None, From adfbe02e85bef5091a8d6772a9c8064d0d7846a3 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 29 Jul 2025 13:07:44 -0500 Subject: [PATCH 6/7] Add test case for incompatible versions --- crates/uv/tests/it/sync.rs | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 3bade6dea7758..5fb59241d6b34 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -11626,5 +11626,52 @@ fn sync_build_dependencies_respect_locked_versions() -> Result<()> { - sniffio==1.3.1 "); + // 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] + build-dependency-strategy = "prefer-locked" + + [tool.uv.sources] + child = { path = "child" } + "#})?; + + // 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 succeed, and use a version within the child constraints + uv_snapshot!(context.filters(), context.sync() + .arg("--reinstall-package").arg("child").env("EXPECTED_ANYIO_VERSION", "4.1"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: The `build-dependency-strategy` setting is experimental and may change without warning. Pass `--preview-features prefer-locked-builds` 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==3.7.1 + ~ child==0.1.0 (from file://[TEMP_DIR]/child) + + idna==3.6 + + sniffio==1.3.1 + "); + Ok(()) } From 9a91e91f78590e7d58bf4cf3da7b54c4734dc191 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 29 Jul 2025 13:13:11 -0500 Subject: [PATCH 7/7] Expand the documentation a little --- crates/uv-settings/src/settings.rs | 6 ++++-- docs/reference/settings.md | 6 ++++-- uv.schema.json | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 254cb204b4122..dbd5fd324f9c0 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -517,8 +517,10 @@ pub struct ResolverInstallerOptions { /// - `prefer-locked`: Prefer the versions pinned in the lockfile, if available. /// /// When set to `prefer-locked`, uv will use the locked versions of packages specified in the - /// lockfile as preferences when resolving build dependencies during source builds. This helps - /// ensure that build environments are consistent with the project's resolved dependencies. + /// lockfile as preferences when resolving build dependencies during source builds, such that + /// the locked version of a package will be used as long as it doesn't conflict with version + /// constraints declared by the package being built. This helps ensure that build environments + /// are consistent with the project's resolved dependencies. #[option( default = "\"latest\"", value_type = "str", diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 31dd1157b59d6..7fed1b2b6a407 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -757,8 +757,10 @@ The strategy to use when resolving build dependencies for source distributions. - `prefer-locked`: Prefer the versions pinned in the lockfile, if available. When set to `prefer-locked`, uv will use the locked versions of packages specified in the -lockfile as preferences when resolving build dependencies during source builds. This helps -ensure that build environments are consistent with the project's resolved dependencies. +lockfile as preferences when resolving build dependencies during source builds, such that +the locked version of a package will be used as long as it doesn't conflict with version +constraints declared by the package being built. This helps ensure that build environments +are consistent with the project's resolved dependencies. **Default value**: `"latest"` diff --git a/uv.schema.json b/uv.schema.json index d592eca73e682..fce59304e3c25 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -47,7 +47,7 @@ } }, "build-dependency-strategy": { - "description": "The strategy to use when resolving build dependencies for source distributions.\n\n- `latest`: Use the latest compatible version of each build dependency.\n- `prefer-locked`: Prefer the versions pinned in the lockfile, if available.\n\nWhen set to `prefer-locked`, uv will use the locked versions of packages specified in the\nlockfile as preferences when resolving build dependencies during source builds. This helps\nensure that build environments are consistent with the project's resolved dependencies.", + "description": "The strategy to use when resolving build dependencies for source distributions.\n\n- `latest`: Use the latest compatible version of each build dependency.\n- `prefer-locked`: Prefer the versions pinned in the lockfile, if available.\n\nWhen set to `prefer-locked`, uv will use the locked versions of packages specified in the\nlockfile as preferences when resolving build dependencies during source builds, such that\nthe locked version of a package will be used as long as it doesn't conflict with version\nconstraints declared by the package being built. This helps ensure that build environments\nare consistent with the project's resolved dependencies.", "anyOf": [ { "$ref": "#/definitions/BuildDependencyStrategy"