diff --git a/crates/uv-bench/benches/uv.rs b/crates/uv-bench/benches/uv.rs index 8adfd5a0e7990..0ccd536b64ea5 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::{ - ExcludeNewer, FlatIndex, InMemoryIndex, Manifest, OptionsBuilder, PythonRequirement, - Resolver, ResolverEnvironment, ResolverOutput, + ExcludeNewer, FlatIndex, InMemoryIndex, Manifest, OptionsBuilder, Preferences, + PythonRequirement, Resolver, ResolverEnvironment, ResolverOutput, }; use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_workspace::WorkspaceCache; @@ -197,6 +197,7 @@ mod resolver { workspace_cache, concurrency, Preview::default(), + Preferences::default(), ); let markers = if universal { diff --git a/crates/uv-cli/src/options.rs b/crates/uv-cli/src/options.rs index c5f94de1b8390..24ffd9093f768 100644 --- a/crates/uv-cli/src/options.rs +++ b/crates/uv-cli/src/options.rs @@ -364,6 +364,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, } } @@ -492,5 +493,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/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"), + } + } +} 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-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-dispatch/src/lib.rs b/crates/uv-dispatch/src/lib.rs index 5d0adb47b2ca6..4c548cf793495 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.clone()) .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-settings/src/combine.rs b/crates/uv-settings/src/combine.rs index a2d78e7b11bf4..ce74bd63a2d5f 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; @@ -80,6 +80,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!(ExcludeNewerTimestamp); impl_combine_or!(ExportFormat); diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index f8385d0066274..1ebe802fc3f23 100644 --- a/crates/uv-settings/src/lib.rs +++ b/crates/uv-settings/src/lib.rs @@ -330,6 +330,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 6062d5d0eb91f..da1dc193ff233 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, @@ -377,6 +378,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 @@ -513,6 +515,25 @@ 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, 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", + 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. /// @@ -1720,6 +1741,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, } } } @@ -1857,6 +1879,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, } } } @@ -1911,6 +1934,7 @@ pub struct OptionsWire { no_build_package: Option>, no_binary: Option, no_binary_package: Option>, + build_dependency_strategy: Option, // #[serde(flatten)] // install_mirror: PythonInstallMirrors, @@ -2002,6 +2026,7 @@ impl From for Options { no_build_package, no_binary, no_binary_package, + build_dependency_strategy, pip, cache_keys, override_dependencies, @@ -2049,6 +2074,7 @@ impl From for Options { find_links, index_strategy, keyring_provider, + build_dependency_strategy, resolution, prerelease, fork_strategy, diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index 78e27e9753bfd..38cf665dbc5a0 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}; @@ -205,6 +205,7 @@ async fn build_impl( upgrade: _, build_options, sources, + build_dependency_strategy: _, } = settings; let client_builder = BaseClientBuilder::default() @@ -581,6 +582,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 dba91e106f6ae..29538f31a8363 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 39092d03fa170..3db324d82ef86 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 9e8943d64e673..67ded42a22743 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 45727b53e7478..0943bba16878f 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -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}; @@ -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,35 @@ pub(crate) async fn add( FlatIndex::from_entries(entries, None, &hasher, &settings.resolver.build_options) }; + // 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()) + .transpose() + }) + .collect::, _>>()?, + &ResolverEnvironment::specific( + target.interpreter().markers().clone().into(), + ), + ) + } else { + Preferences::default() + } + } + BuildDependencyStrategy::Latest => Preferences::default(), + }; + // Create a build dispatch. let build_dispatch = BuildDispatch::new( &client, @@ -450,6 +479,7 @@ pub(crate) async fn add( WorkspaceCache::default(), concurrency, preview, + preferences, ); requirements.extend( @@ -1318,6 +1348,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 fd9536b64855a..6d3214a251f49 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, PreviewFeatures, Reinstall, Upgrade, }; use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; @@ -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}; @@ -440,8 +440,19 @@ async fn do_lock( upgrade, build_options, sources, + 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(); @@ -663,6 +674,26 @@ 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, 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( &client, @@ -685,6 +716,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 052d41beaa265..095711a53b715 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; @@ -1696,6 +1696,7 @@ pub(crate) async fn resolve_names( resolution: _, sources, upgrade: _, + build_dependency_strategy: _, }, compile_bytecode: _, reinstall: _, @@ -1761,6 +1762,7 @@ pub(crate) async fn resolve_names( workspace_cache.clone(), concurrency, preview, + Preferences::default(), ); // Resolve the unnamed requirements. @@ -1850,6 +1852,7 @@ pub(crate) async fn resolve_environment( upgrade: _, build_options, sources, + build_dependency_strategy: _, } = settings; // Respect all requirements from the provided sources. @@ -1969,6 +1972,7 @@ pub(crate) async fn resolve_environment( workspace_cache, concurrency, preview, + Preferences::default(), ); // Resolve the requirements. @@ -2034,6 +2038,7 @@ pub(crate) async fn sync_environment( reinstall, build_options, sources, + build_dependency_strategy: _, } = settings; let client_builder = BaseClientBuilder::new() @@ -2107,6 +2112,7 @@ pub(crate) async fn sync_environment( workspace_cache, concurrency, preview, + Preferences::default(), ); // Sync the environment. @@ -2198,6 +2204,7 @@ pub(crate) async fn update_environment( resolution, sources, upgrade, + build_dependency_strategy: _, }, compile_bytecode, reinstall, @@ -2331,6 +2338,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 197fbc3433904..e83021351bfb7 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::{ @@ -26,11 +27,11 @@ use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; use uv_pep508::{MarkerTree, VersionOrUrl}; use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl, ParsedUrl}; use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; -use uv_resolver::{FlatIndex, Installable, Lock}; +use uv_resolver::{FlatIndex, Installable, Lock, Preference, Preferences, ResolverEnvironment}; 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}; @@ -585,6 +586,7 @@ pub(super) async fn do_sync( reinstall, build_options, sources, + build_dependency_strategy, } = settings; let client_builder = BaseClientBuilder::new() @@ -701,6 +703,29 @@ pub(super) async fn do_sync( FlatIndex::from_entries(entries, Some(&tags), &hasher, build_options) }; + 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( &client, @@ -723,6 +748,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/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/commands/venv.rs b/crates/uv/src/commands/venv.rs index f391ef49904d3..f673779ed9afa 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/src/settings.rs b/crates/uv/src/settings.rs index bc09e8257f18b..60b6542e91710 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; @@ -2720,6 +2720,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. @@ -2744,6 +2745,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 { @@ -2808,6 +2810,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(), } } } @@ -2901,6 +2904,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( @@ -3069,6 +3073,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: _, exclude_newer_package: top_level_exclude_newer_package, } = top_level; @@ -3309,6 +3314,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 92d0d7b6a22af..9337f6ad0eb5f 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -13595,3 +13595,196 @@ 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"] + + [tool.uv] + build-dependency-strategy = "prefer-locked" + "#})?; + + // Create a lockfile with anyio 4.0.0 + uv_snapshot!(context.filters(), context.lock(), @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] + "); + + // 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 ----- + 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` + ├─▶ 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 ----- + 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] + 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] + build-dependency-strategy = "prefer-locked" + + [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 ----- + 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==4.0.0 + + anyio==3.7.1 + ~ 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] + Uninstalled [N] packages in [TIME] + Installed [N] packages in [TIME] + ~ 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 9de38cb313222..1543ac774a05a 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -3397,6 +3397,7 @@ fn resolve_tool() -> anyhow::Result<()> { find_links: None, index_strategy: None, keyring_provider: None, + build_dependency_strategy: None, resolution: Some( LowestDirect, ), @@ -3459,6 +3460,7 @@ fn resolve_tool() -> anyhow::Result<()> { resolution: LowestDirect, sources: Enabled, upgrade: None, + build_dependency_strategy: Latest, }, compile_bytecode: false, reinstall: None, @@ -4490,7 +4492,7 @@ fn resolve_config_file() -> anyhow::Result<()> { | 1 | [project] | ^^^^^^^ - unknown field `project`, expected one of `required-version`, `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `fork-strategy`, `dependency-metadata`, `config-settings`, `config-settings-package`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `exclude-newer-package`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `python-install-mirror`, `pypy-install-mirror`, `python-downloads-json-url`, `publish-url`, `trusted-publishing`, `check-url`, `add-bounds`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `build-constraint-dependencies`, `environments`, `required-environments`, `conflicts`, `workspace`, `sources`, `managed`, `package`, `default-groups`, `dependency-groups`, `dev-dependencies`, `build-backend` + unknown field `project`, expected one of `required-version`, `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `fork-strategy`, `dependency-metadata`, `config-settings`, `config-settings-package`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `exclude-newer-package`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `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` " ); @@ -7501,7 +7503,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, @@ -7576,6 +7578,7 @@ fn preview_features() { resolution: Highest, sources: Enabled, upgrade: None, + build_dependency_strategy: Latest, }, compile_bytecode: false, reinstall: None, @@ -7683,6 +7686,7 @@ fn preview_features() { resolution: Highest, sources: Enabled, upgrade: None, + build_dependency_strategy: Latest, }, compile_bytecode: false, reinstall: None, @@ -7715,7 +7719,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, @@ -7790,6 +7794,7 @@ fn preview_features() { resolution: Highest, sources: Enabled, upgrade: None, + build_dependency_strategy: Latest, }, compile_bytecode: false, reinstall: None, @@ -7897,6 +7902,7 @@ fn preview_features() { resolution: Highest, sources: Enabled, upgrade: None, + build_dependency_strategy: Latest, }, compile_bytecode: false, reinstall: None, @@ -8004,6 +8010,7 @@ fn preview_features() { resolution: Highest, sources: Enabled, upgrade: None, + build_dependency_strategy: Latest, }, compile_bytecode: false, reinstall: None, @@ -8113,6 +8120,7 @@ fn preview_features() { resolution: Highest, sources: Enabled, upgrade: None, + build_dependency_strategy: Latest, }, compile_bytecode: false, reinstall: None, diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 48e03c5bc866b..ee7ace93decdd 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -11537,3 +11537,292 @@ 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 with `build-dependency-strategy = "prefer-locked"` + pyproject_toml.write_str(indoc! {r#" + [project] + name = "parent" + version = "0.1.0" + requires-python = ">=3.9" + dependencies = ["anyio<4.1", "child"] + + [tool.uv] + build-dependency-strategy = "prefer-locked" + + [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 ----- + 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 + ╰─▶ 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 ----- + 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] + + 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] + build-dependency-strategy = "prefer-locked" + + [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 ----- + 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==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("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 + "); + + // 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(()) +} diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 4a1d74b42716e..c821644099629 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -749,6 +749,42 @@ 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, 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"` + +**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 a2f3f0113b202..cecd411d1832d 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, 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" + }, + { + "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": [ @@ -737,6 +748,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": [ {