diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index 3b24a36bbbd1e..7a5859435efe4 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -24,7 +24,7 @@ use uv_distribution_filename::{ }; use uv_distribution_types::{ ConfigSettings, DependencyMetadata, ExtraBuildVariables, Index, IndexLocations, - PackageConfigSettings, RequiresPython, SourceDist, + PackageConfigSettings, Requirement, RequiresPython, SourceDist, }; use uv_fs::{Simplified, relative_to}; use uv_install_wheel::LinkMode; @@ -113,6 +113,7 @@ pub(crate) async fn build_frontend( force_pep517: bool, clear: bool, build_constraints: Vec, + build_constraints_from_workspace: Vec, hash_checking: Option, python: Option, install_mirrors: PythonInstallMirrors, @@ -141,6 +142,7 @@ pub(crate) async fn build_frontend( force_pep517, clear, &build_constraints, + &build_constraints_from_workspace, hash_checking, python.as_deref(), install_mirrors, @@ -189,6 +191,7 @@ async fn build_impl( force_pep517: bool, clear: bool, build_constraints: &[RequirementsSource], + build_constraints_from_workspace: &[Requirement], hash_checking: Option, python_request: Option<&str>, install_mirrors: PythonInstallMirrors, @@ -358,6 +361,7 @@ async fn build_impl( force_pep517, clear, build_constraints, + build_constraints_from_workspace, build_isolation, extra_build_dependencies, extra_build_variables, @@ -468,6 +472,7 @@ async fn build_package( force_pep517: bool, clear: bool, build_constraints: &[RequirementsSource], + build_constraints_from_workspace: &[Requirement], build_isolation: &BuildIsolation, extra_build_dependencies: &ExtraBuildDependencies, extra_build_variables: &ExtraBuildVariables, @@ -571,7 +576,8 @@ async fn build_package( let build_constraints = Constraints::from_requirements( build_constraints .into_iter() - .map(|constraint| constraint.requirement), + .map(|constraint| constraint.requirement) + .chain(build_constraints_from_workspace.iter().cloned()), ); // Initialize the registry client. diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 146214109650e..645e50943ce2f 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1173,6 +1173,7 @@ async fn run(mut cli: Cli) -> Result { args.force_pep517, args.clear, build_constraints, + args.build_constraints_from_workspace, args.hash_checking, args.python, args.install_mirrors, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 1655a7457b18d..842ba909b023b 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -3364,6 +3364,7 @@ pub(crate) struct BuildSettings { pub(crate) force_pep517: bool, pub(crate) clear: bool, pub(crate) build_constraints: Vec, + pub(crate) build_constraints_from_workspace: Vec, pub(crate) hash_checking: Option, pub(crate) python: Option, pub(crate) install_mirrors: PythonInstallMirrors, @@ -3406,6 +3407,19 @@ impl BuildSettings { Some(fs) => fs.install_mirrors.clone(), None => PythonInstallMirrors::default(), }; + let build_constraints_from_workspace = if let Some(configuration) = &filesystem { + configuration + .build_constraint_dependencies + .clone() + .unwrap_or_default() + .into_iter() + .map(|requirement| { + Requirement::from(requirement.with_origin(RequirementOrigin::Workspace)) + }) + .collect() + } else { + Vec::new() + }; Self { src, @@ -3424,6 +3438,7 @@ impl BuildSettings { .into_iter() .filter_map(Maybe::into_option) .collect(), + build_constraints_from_workspace, hash_checking: HashCheckingMode::from_args( flag(require_hashes, no_require_hashes, "require-hashes"), flag(verify_hashes, no_verify_hashes, "verify-hashes"), diff --git a/crates/uv/tests/it/build.rs b/crates/uv/tests/it/build.rs index df33dbe910d77..7532c3c4dfa20 100644 --- a/crates/uv/tests/it/build.rs +++ b/crates/uv/tests/it/build.rs @@ -7,7 +7,7 @@ use insta::assert_snapshot; use predicates::prelude::predicate; use std::env::current_dir; use uv_static::EnvVars; -use uv_test::{DEFAULT_PYTHON_VERSION, uv_snapshot}; +use uv_test::{DEFAULT_PYTHON_VERSION, apply_filters, uv_snapshot}; use zip::ZipArchive; #[test] @@ -894,6 +894,190 @@ fn build_constraints() -> Result<()> { Ok(()) } +/// Regression test for . +/// +/// `uv build --all` should respect `tool.uv.build-constraint-dependencies` declared at the +/// workspace root. +#[test] +fn build_all_respects_workspace_build_constraint_dependencies() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let filters = context + .filters() + .into_iter() + .chain([(r"\\\.", ""), (r"\[member\]", "[PKG]")]) + .collect::>(); + + let project = context.temp_dir.child("project"); + + project.child("pyproject.toml").write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [tool.uv] + build-constraint-dependencies = ["hatchling==0.1.0"] + + [tool.uv.workspace] + members = ["packages/*"] + "#, + )?; + + project + .child("src") + .child("project") + .child("__init__.py") + .touch()?; + project.child("README").touch()?; + + let member = project.child("packages").child("member"); + fs_err::create_dir_all(member.path())?; + + member.child("pyproject.toml").write_str( + r#" + [project] + name = "member" + version = "0.1.0" + requires-python = ">=3.12" + + [build-system] + requires = ["hatchling>=1.0"] + build-backend = "hatchling.build" + "#, + )?; + + member + .child("src") + .child("member") + .child("__init__.py") + .touch()?; + member.child("README").touch()?; + + let output = context + .build() + .arg("--all") + .arg("--no-build-logs") + .current_dir(&project) + .output()?; + + let stderr = apply_filters( + String::from_utf8_lossy(&output.stderr).into_owned(), + &filters, + ); + + assert!( + !output.status.success(), + "expected `uv build --all` to fail when workspace build constraints make `hatchling` \ + unsatisfiable, but it succeeded:\n{stderr}" + ); + assert_eq!(output.status.code(), Some(2)); + assert!( + stderr.contains("Failed to resolve requirements from `build-system.requires`"), + "expected build constraint failure in stderr:\n{stderr}" + ); + assert!( + stderr.contains( + "Because you require hatchling>=1.0 and hatchling==0.1.0, we can conclude that your requirements are unsatisfiable." + ), + "expected incompatible build constraints in stderr:\n{stderr}" + ); + + project + .child("dist") + .child("member-0.1.0.tar.gz") + .assert(predicate::path::missing()); + project + .child("dist") + .child("member-0.1.0-py3-none-any.whl") + .assert(predicate::path::missing()); + + Ok(()) +} + +/// Limitation: when `uv build` is invoked with an explicit source path, configuration is still +/// loaded from the invocation directory instead of the source workspace. As a result, workspace +/// `build-constraint-dependencies` are not applied here. +#[test] +fn build_source_path_ignores_workspace_build_constraint_dependencies() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let filters = context + .filters() + .into_iter() + .chain([(r"\\\.", ""), (r"\[member\]", "[PKG]")]) + .collect::>(); + + let project = context.temp_dir.child("project"); + + project.child("pyproject.toml").write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [tool.uv] + build-constraint-dependencies = ["hatchling==0.1.0"] + + [tool.uv.workspace] + members = ["packages/*"] + "#, + )?; + + project + .child("src") + .child("project") + .child("__init__.py") + .touch()?; + project.child("README").touch()?; + + let member = project.child("packages").child("member"); + fs_err::create_dir_all(member.path())?; + + member.child("pyproject.toml").write_str( + r#" + [project] + name = "member" + version = "0.1.0" + requires-python = ">=3.12" + + [build-system] + requires = ["hatchling>=1.0"] + build-backend = "hatchling.build" + "#, + )?; + + member + .child("src") + .child("member") + .child("__init__.py") + .touch()?; + member.child("README").touch()?; + + uv_snapshot!(&filters, context.build().arg("./project").arg("--all").arg("--no-build-logs"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + [PKG] Building source distribution... + [PKG] Building wheel from source distribution... + Successfully built project/dist/member-0.1.0.tar.gz + Successfully built project/dist/member-0.1.0-py3-none-any.whl + "); + + project + .child("dist") + .child("member-0.1.0.tar.gz") + .assert(predicate::path::is_file()); + project + .child("dist") + .child("member-0.1.0-py3-none-any.whl") + .assert(predicate::path::is_file()); + + Ok(()) +} + #[test] fn build_sha() -> Result<()> { let context = uv_test::test_context!(DEFAULT_PYTHON_VERSION); diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 99cecfdcf9383..327e3fe5489ab 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -12342,6 +12342,102 @@ fn sync_build_constraints() -> Result<()> { Ok(()) } +/// `uv sync --package ` should respect workspace build constraints for the selected +/// member's transitive build dependencies. +/// +/// See: +#[test] +fn sync_workspace_member_build_constraints() -> Result<()> { + let context = uv_test::test_context!("3.12") + .with_exclude_newer("2025-03-24T19:00:00Z") + .with_filtered_counts(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "root" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["child"] + + [tool.uv] + build-constraint-dependencies = ["setuptools<78"] + + [tool.uv.sources] + child = { workspace = true } + + [tool.uv.workspace] + members = ["child"] + "#, + )?; + context + .temp_dir + .child("src") + .child("root") + .child("__init__.py") + .touch()?; + + let child = context.temp_dir.child("child"); + child.child("pyproject.toml").write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["json-merge-patch"] + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "#, + )?; + child + .child("src") + .child("child") + .child("__init__.py") + .touch()?; + + uv_snapshot!(context.filters(), context.sync().arg("--package").arg("child").arg("--no-binary-package").arg("json-merge-patch"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + child==0.1.0 (from file://[TEMP_DIR]/child) + + json-merge-patch==0.2 + "); + + let lock = context.read("uv.lock"); + assert!( + lock.contains(r#"build-constraints = [{ name = "setuptools", specifier = "<78" }]"#), + "expected workspace build constraints to be recorded in the lockfile:\n{lock}" + ); + + fs_err::remove_dir_all(&context.cache_dir)?; + fs_err::remove_dir_all(&context.venv)?; + + uv_snapshot!(context.filters(), context.sync().arg("--package").arg("child").arg("--locked").arg("--no-binary-package").arg("json-merge-patch"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + child==0.1.0 (from file://[TEMP_DIR]/child) + + json-merge-patch==0.2 + "); + + Ok(()) +} + // Test that we recreate a virtual environment when `pyvenv.cfg` version // is incompatible with the interpreter version. #[test]