diff --git a/crates/uv-distribution-types/src/requirement.rs b/crates/uv-distribution-types/src/requirement.rs index c67d09052..79474646c 100644 --- a/crates/uv-distribution-types/src/requirement.rs +++ b/crates/uv-distribution-types/src/requirement.rs @@ -78,7 +78,7 @@ impl Requirement { self.source.is_editable() } - /// Convert the requirement to a [`Requirement`] relative to the given path. + /// Convert to a [`Requirement`] with a relative path based on the given root. pub fn relative_to(self, path: &Path) -> Result { Ok(Self { source: self.source.relative_to(path)?, @@ -86,6 +86,15 @@ impl Requirement { }) } + /// Convert to a [`Requirement`] with an absolute path based on the given root. + #[must_use] + pub fn to_absolute(self, path: &Path) -> Self { + Self { + source: self.source.to_absolute(path), + ..self + } + } + /// Return the hashes of the requirement, as specified in the URL fragment. pub fn hashes(&self) -> Option { let RequirementSource::Url { ref url, .. } = self.source else { @@ -593,6 +602,37 @@ impl RequirementSource { }), } } + + /// Convert the source to a [`RequirementSource`] with an absolute path based on the given root. + #[must_use] + pub fn to_absolute(self, root: &Path) -> Self { + match self { + RequirementSource::Registry { .. } + | RequirementSource::Url { .. } + | RequirementSource::Git { .. } => self, + RequirementSource::Path { + install_path, + ext, + url, + } => Self::Path { + install_path: uv_fs::normalize_path_buf(root.join(install_path)).into_boxed_path(), + ext, + url, + }, + RequirementSource::Directory { + install_path, + editable, + r#virtual, + url, + .. + } => Self::Directory { + install_path: uv_fs::normalize_path_buf(root.join(install_path)).into_boxed_path(), + editable, + r#virtual, + url, + }, + } + } } impl Display for RequirementSource { diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index e1d34f8b7..1e8da1ced 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -19,7 +19,7 @@ use tracing::debug; use url::Url; use uv_cache_key::RepositoryUrl; -use uv_configuration::BuildOptions; +use uv_configuration::{BuildOptions, Constraints}; use uv_distribution::{DistributionDatabase, FlatRequiresDist}; use uv_distribution_filename::{ BuildTag, DistExtension, ExtensionError, SourceDistExtension, WheelFilename, @@ -674,6 +674,17 @@ impl Lock { &self.manifest.dependency_groups } + /// Returns the build constraints that were used to generate this lock. + pub fn build_constraints(&self, root: &Path) -> Constraints { + Constraints::from_requirements( + self.manifest + .build_constraints + .iter() + .cloned() + .map(|requirement| requirement.to_absolute(root)), + ) + } + /// Return the workspace root used to generate this lock. pub fn root(&self) -> Option<&Package> { self.packages.iter().find(|package| { @@ -931,6 +942,26 @@ impl Lock { manifest_table.insert("overrides", value(overrides)); } + if !self.manifest.build_constraints.is_empty() { + let build_constraints = self + .manifest + .build_constraints + .iter() + .map(|requirement| { + serde::Serialize::serialize( + &requirement, + toml_edit::ser::ValueSerializer::new(), + ) + }) + .collect::, _>>()?; + let build_constraints = match build_constraints.as_slice() { + [] => Array::new(), + [requirement] => Array::from_iter([requirement]), + build_constraints => each_element_on_its_line_array(build_constraints.iter()), + }; + manifest_table.insert("build-constraints", value(build_constraints)); + } + if !self.manifest.dependency_groups.is_empty() { let mut dependency_groups = Table::new(); for (extra, requirements) in &self.manifest.dependency_groups { @@ -1188,6 +1219,7 @@ impl Lock { requirements: &[Requirement], constraints: &[Requirement], overrides: &[Requirement], + build_constraints: &[Requirement], dependency_groups: &BTreeMap>, dependency_metadata: &DependencyMetadata, indexes: Option<&IndexLocations>, @@ -1279,6 +1311,27 @@ impl Lock { } } + // Validate that the lockfile was generated with the same build constraints. + { + let expected: BTreeSet<_> = build_constraints + .iter() + .cloned() + .map(|requirement| normalize_requirement(requirement, root)) + .collect::>()?; + let actual: BTreeSet<_> = self + .manifest + .build_constraints + .iter() + .cloned() + .map(|requirement| normalize_requirement(requirement, root)) + .collect::>()?; + if expected != actual { + return Ok(SatisfiesResult::MismatchedBuildConstraints( + expected, actual, + )); + } + } + // Validate that the lockfile was generated with the dependency groups. { let expected: BTreeMap> = dependency_groups @@ -1685,6 +1738,8 @@ pub enum SatisfiesResult<'lock> { MismatchedConstraints(BTreeSet, BTreeSet), /// The lockfile uses a different set of overrides. MismatchedOverrides(BTreeSet, BTreeSet), + /// The lockfile uses a different set of build constraints. + MismatchedBuildConstraints(BTreeSet, BTreeSet), /// The lockfile uses a different set of dependency groups. MismatchedDependencyGroups( BTreeMap>, @@ -1765,6 +1820,9 @@ pub struct ResolverManifest { /// The overrides provided to the resolver. #[serde(default)] overrides: BTreeSet, + /// The build constraints provided to the resolver. + #[serde(default)] + build_constraints: BTreeSet, /// The static metadata provided to the resolver. #[serde(default)] dependency_metadata: BTreeSet, @@ -1778,6 +1836,7 @@ impl ResolverManifest { requirements: impl IntoIterator, constraints: impl IntoIterator, overrides: impl IntoIterator, + build_constraints: impl IntoIterator, dependency_groups: impl IntoIterator)>, dependency_metadata: impl IntoIterator, ) -> Self { @@ -1786,6 +1845,7 @@ impl ResolverManifest { requirements: requirements.into_iter().collect(), constraints: constraints.into_iter().collect(), overrides: overrides.into_iter().collect(), + build_constraints: build_constraints.into_iter().collect(), dependency_groups: dependency_groups .into_iter() .map(|(group, requirements)| (group, requirements.into_iter().collect())) @@ -1813,6 +1873,11 @@ impl ResolverManifest { .into_iter() .map(|requirement| requirement.relative_to(root)) .collect::, _>>()?, + build_constraints: self + .build_constraints + .into_iter() + .map(|requirement| requirement.relative_to(root)) + .collect::, _>>()?, dependency_groups: self .dependency_groups .into_iter() @@ -2375,11 +2440,8 @@ impl Package { url.set_query(None); // Reconstruct the `GitUrl` from the `GitSource`. - let git_url = uv_git_types::GitUrl::from_commit( - url, - GitReference::from(git.kind.clone()), - git.precise, - )?; + let git_url = + GitUrl::from_commit(url, GitReference::from(git.kind.clone()), git.precise)?; // Reconstruct the PEP 508-compatible URL from the `GitSource`. let url = Url::from(ParsedGitUrl { diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap index 0ba638f55..72c185392 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap @@ -117,6 +117,7 @@ Ok( dependency_groups: {}, constraints: {}, overrides: {}, + build_constraints: {}, dependency_metadata: {}, }, }, diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_present.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_present.snap index 7649bbdf7..20b4c5548 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_present.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_present.snap @@ -124,6 +124,7 @@ Ok( dependency_groups: {}, constraints: {}, overrides: {}, + build_constraints: {}, dependency_metadata: {}, }, }, diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_required_present.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_required_present.snap index 41756ee94..09230c1f4 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_required_present.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_required_present.snap @@ -116,6 +116,7 @@ Ok( dependency_groups: {}, constraints: {}, overrides: {}, + build_constraints: {}, dependency_metadata: {}, }, }, diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap index d7bfb88c8..046da8d93 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap @@ -195,6 +195,7 @@ Ok( dependency_groups: {}, constraints: {}, overrides: {}, + build_constraints: {}, dependency_metadata: {}, }, }, diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap index d7bfb88c8..046da8d93 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap @@ -195,6 +195,7 @@ Ok( dependency_groups: {}, constraints: {}, overrides: {}, + build_constraints: {}, dependency_metadata: {}, }, }, diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_dynamic.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_dynamic.snap index b622c2fdf..fc41abcce 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_dynamic.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_dynamic.snap @@ -220,6 +220,7 @@ Ok( dependency_groups: {}, constraints: {}, overrides: {}, + build_constraints: {}, dependency_metadata: {}, }, }, diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap index d7bfb88c8..046da8d93 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap @@ -195,6 +195,7 @@ Ok( dependency_groups: {}, constraints: {}, overrides: {}, + build_constraints: {}, dependency_metadata: {}, }, }, diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap index 0ab38c9b4..df411251c 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap @@ -97,6 +97,7 @@ Ok( dependency_groups: {}, constraints: {}, overrides: {}, + build_constraints: {}, dependency_metadata: {}, }, }, diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap index 5fcdb2c0f..a0519d53a 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap @@ -93,6 +93,7 @@ Ok( dependency_groups: {}, constraints: {}, overrides: {}, + build_constraints: {}, dependency_metadata: {}, }, }, diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_directory.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_directory.snap index 335b369dd..4ac13fff9 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_directory.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_directory.snap @@ -83,6 +83,7 @@ Ok( dependency_groups: {}, constraints: {}, overrides: {}, + build_constraints: {}, dependency_metadata: {}, }, }, diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_editable.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_editable.snap index c59b608ef..0244bfc40 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_editable.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_editable.snap @@ -83,6 +83,7 @@ Ok( dependency_groups: {}, constraints: {}, overrides: {}, + build_constraints: {}, dependency_metadata: {}, }, }, diff --git a/crates/uv/src/commands/project/install_target.rs b/crates/uv/src/commands/project/install_target.rs index 236df5cd9..eaea63de0 100644 --- a/crates/uv/src/commands/project/install_target.rs +++ b/crates/uv/src/commands/project/install_target.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use itertools::Either; use rustc_hash::FxHashSet; -use uv_configuration::{DependencyGroupsWithDefaults, ExtrasSpecification}; +use uv_configuration::{Constraints, DependencyGroupsWithDefaults, ExtrasSpecification}; use uv_distribution_types::Index; use uv_normalize::PackageName; use uv_pypi_types::{DependencyGroupSpecifier, LenientRequirement, VerbatimParsedUrl}; @@ -236,6 +236,10 @@ impl<'lock> InstallTarget<'lock> { } } + pub(crate) fn build_constraints(&self) -> Constraints { + self.lock().build_constraints(self.install_path()) + } + /// Validate the extras requested by the [`ExtrasSpecification`]. #[allow(clippy::result_large_err)] pub(crate) fn validate_extras(self, extras: &ExtrasSpecification) -> Result<(), ProjectError> { diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 514a6ca0d..3675ab204 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -624,8 +624,6 @@ async fn do_lock( .build(); let hasher = HashStrategy::Generate(HashGeneration::Url); - let build_constraints = Constraints::from_requirements(build_constraints.iter().cloned()); - // TODO(charlie): These are all default values. We should consider whether we want to make them // optional on the downstream APIs. let build_hasher = HashStrategy::default(); @@ -647,7 +645,7 @@ async fn do_lock( let build_dispatch = BuildDispatch::new( &client, cache, - build_constraints, + Constraints::from_requirements(build_constraints.iter().cloned()), interpreter, index_locations, &flat_index, @@ -679,6 +677,7 @@ async fn do_lock( &dependency_groups, &constraints, &overrides, + &build_constraints, &conflicts, environments, required_environments, @@ -837,6 +836,7 @@ async fn do_lock( requirements, constraints, overrides, + build_constraints, dependency_groups, dependency_metadata.values().cloned(), ) @@ -889,6 +889,7 @@ impl ValidatedLock { dependency_groups: &BTreeMap>, constraints: &[Requirement], overrides: &[Requirement], + build_constraints: &[Requirement], conflicts: &Conflicts, environments: Option<&SupportedEnvironments>, required_environments: Option<&SupportedEnvironments>, @@ -1066,6 +1067,7 @@ impl ValidatedLock { requirements, constraints, overrides, + build_constraints, dependency_groups, dependency_metadata, indexes, @@ -1144,6 +1146,13 @@ impl ValidatedLock { ); Ok(Self::Preferable(lock)) } + SatisfiesResult::MismatchedBuildConstraints(expected, actual) => { + debug!( + "Ignoring existing lockfile due to mismatched build constraints:\n Requested: {:?}\n Existing: {:?}", + expected, actual + ); + Ok(Self::Preferable(lock)) + } SatisfiesResult::MismatchedDependencyGroups(expected, actual) => { debug!( "Ignoring existing lockfile due to mismatched dependency groups:\n Requested: {:?}\n Existing: {:?}", diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index aed35e01d..db98dfd06 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -11,7 +11,7 @@ use uv_auth::UrlAuthPolicies; use uv_cache::Cache; use uv_client::{FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - Concurrency, Constraints, DependencyGroups, DependencyGroupsWithDefaults, DryRun, EditableMode, + Concurrency, DependencyGroups, DependencyGroupsWithDefaults, DryRun, EditableMode, ExtrasSpecification, HashCheckingMode, InstallOptions, PreviewMode, }; use uv_dispatch::BuildDispatch; @@ -644,9 +644,11 @@ pub(super) async fn do_sync( BuildIsolation::SharedPackage(venv, no_build_isolation_package) }; + // Read the build constraints from the lockfile. + let build_constraints = target.build_constraints(); + // TODO(charlie): These are all default values. We should consider whether we want to make them // optional on the downstream APIs. - let build_constraints = Constraints::default(); let build_hasher = HashStrategy::default(); // Extract the hashes from the lockfile. diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index bd180899a..2d2e97757 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -8924,3 +8924,135 @@ fn locked_version_coherence() -> Result<()> { Ok(()) } + +/// `uv sync` should respect build constraints. In this case, `json-merge-patch` should _not_ fail +/// to build, despite the fact that `setuptools==78.0.1` is the most recent version and _does_ fail +/// to build that package. +/// +/// See: +#[test] +fn sync_build_constraints() -> Result<()> { + let context = TestContext::new("3.12").with_exclude_newer("2025-03-24T19:00:00Z"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["json-merge-patch"] + + [tool.uv] + build-constraint-dependencies = ["setuptools<78"] + "#, + )?; + + uv_snapshot!(context.filters(), context.sync().arg("--no-binary-package").arg("json-merge-patch"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + json-merge-patch==0.2 + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!( + { + filters => context.filters(), + }, + { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2025-03-24T19:00:00Z" + + [manifest] + build-constraints = [{ name = "setuptools", specifier = "<78" }] + + [[package]] + name = "json-merge-patch" + version = "0.2" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/39/62/3b783faabac9a099877397d8f7a7cc862a03fbf9fb1b90d414ea7c6bb096/json-merge-patch-0.2.tar.gz", hash = "sha256:09898b6d427c08754e2a97c709cf2dfd7e28bd10c5683a538914975eab778d39", size = 3081 } + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "json-merge-patch" }, + ] + + [package.metadata] + requires-dist = [{ name = "json-merge-patch" }] + "# + ); + } + ); + + fs_err::remove_dir_all(&context.cache_dir)?; + fs_err::remove_dir_all(&context.venv)?; + + // We should also be able to read from the lockfile. + uv_snapshot!(context.filters(), context.sync().arg("--locked"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + json-merge-patch==0.2 + "); + + // Modify the build constraints. + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["json-merge-patch"] + + [tool.uv] + build-constraint-dependencies = ["setuptools<77"] + "#, + )?; + + // This should fail, given that the build constraints have changed. + uv_snapshot!(context.filters(), context.sync().arg("--locked"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`. + "); + + // Changing the build constraints should lead to a re-resolve. + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Audited 1 package in [TIME] + "); + + Ok(()) +}