From 80f51cee066840400db6f22ea3d594b7744d6da0 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 5 Sep 2024 14:46:36 -0400 Subject: [PATCH] Accept `--build-constraints` in `uv build` (#7085) ## Summary Closes #7082. Closes #7065. --- crates/uv-cli/src/lib.rs | 9 ++ crates/uv/src/commands/build.rs | 36 ++++- crates/uv/src/commands/pip/compile.rs | 2 +- crates/uv/src/lib.rs | 8 + crates/uv/src/settings.rs | 6 + crates/uv/tests/build.rs | 222 ++++++++++++++++++++++++++ docs/concepts/projects.md | 17 ++ docs/reference/cli.md | 5 + 8 files changed, 296 insertions(+), 9 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index ef46cf7b3b14..fe91416972ea 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1979,6 +1979,15 @@ pub struct BuildArgs { #[arg(long)] pub wheel: bool, + /// Constrain build dependencies using the given requirements files when building + /// distributions. + /// + /// Constraints files are `requirements.txt`-like files that only control the _version_ of a + /// build dependency that's installed. However, including a package in a constraints file will + /// _not_ trigger the inclusion of that package on its own. + #[arg(long, short, env = "UV_BUILD_CONSTRAINT", value_delimiter = ' ', value_parser = parse_maybe_file_path)] + pub build_constraint: Vec>, + /// The Python interpreter to use for the build environment. /// /// By default, builds are executed in isolated virtual environments. The diff --git a/crates/uv/src/commands/build.rs b/crates/uv/src/commands/build.rs index 67f54676cb1e..bf080531b697 100644 --- a/crates/uv/src/commands/build.rs +++ b/crates/uv/src/commands/build.rs @@ -5,6 +5,7 @@ use crate::printer::Printer; use crate::settings::{ResolverSettings, ResolverSettingsRef}; use std::borrow::Cow; +use crate::commands::pip::operations; use anyhow::Result; use distribution_filename::SourceDistExtension; use owo_colors::OwoColorize; @@ -12,7 +13,7 @@ use std::path::{Path, PathBuf}; use uv_auth::store_credentials_from_url; use uv_cache::Cache; use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder}; -use uv_configuration::{BuildKind, BuildOutput, Concurrency, Constraints}; +use uv_configuration::{BuildKind, BuildOutput, Concurrency, Constraints, HashCheckingMode}; use uv_dispatch::BuildDispatch; use uv_fs::{Simplified, CWD}; use uv_normalize::PackageName; @@ -20,6 +21,7 @@ use uv_python::{ EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile, VersionRequest, }; +use uv_requirements::RequirementsSource; use uv_resolver::{FlatIndex, RequiresPython}; use uv_types::{BuildContext, BuildIsolation, HashStrategy}; use uv_workspace::{DiscoveryOptions, Workspace}; @@ -32,6 +34,7 @@ pub(crate) async fn build( output_dir: Option, sdist: bool, wheel: bool, + build_constraints: Vec, python: Option, settings: ResolverSettings, no_config: bool, @@ -49,6 +52,7 @@ pub(crate) async fn build( output_dir.as_deref(), sdist, wheel, + &build_constraints, python.as_deref(), settings.as_ref(), no_config, @@ -88,6 +92,7 @@ async fn build_impl( output_dir: Option<&Path>, sdist: bool, wheel: bool, + build_constraints: &[RequirementsSource], python_request: Option<&str>, settings: ResolverSettingsRef<'_>, no_config: bool, @@ -225,6 +230,27 @@ async fn build_impl( store_credentials_from_url(url); } + // Read build constraints. + let build_constraints = + operations::read_constraints(build_constraints, &client_builder).await?; + + // Collect the set of required hashes. + // Enforce (but never require) the build constraints, if `--require-hashes` or `--verify-hashes` + // is provided. _Requiring_ hashes would be too strict, and would break with pip. + let build_hasher = HashStrategy::from_requirements( + std::iter::empty(), + build_constraints + .iter() + .map(|entry| (&entry.requirement, entry.hashes.as_slice())), + Some(&interpreter.resolver_markers()), + HashCheckingMode::Verify, + )?; + let build_constraints = Constraints::from_requirements( + build_constraints + .iter() + .map(|constraint| constraint.requirement.clone()), + ); + // Initialize the registry client. let client = RegistryClientBuilder::new(cache.clone()) .native_tls(native_tls) @@ -249,17 +275,11 @@ async fn build_impl( BuildIsolation::SharedPackage(&environment, no_build_isolation_package) }; - // 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(); - let hasher = HashStrategy::None; - // Resolve the flat indexes from `--find-links`. let flat_index = { let client = FlatIndexClient::new(&client, cache); let entries = client.fetch(index_locations.flat_index()).await?; - FlatIndex::from_entries(entries, None, &hasher, build_options) + FlatIndex::from_entries(entries, None, &build_hasher, build_options) }; // Initialize any shared state. diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index d65c424aa592..de823097d13b 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -321,12 +321,12 @@ pub(crate) async fn pip_compile( }; // Don't enforce hashes in `pip compile`. + let build_hashes = HashStrategy::None; let build_constraints = Constraints::from_requirements( build_constraints .iter() .map(|constraint| constraint.requirement.clone()), ); - let build_hashes = HashStrategy::None; let build_dispatch = BuildDispatch::new( &client, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index ec211c035a47..e10abee99561 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -670,12 +670,20 @@ async fn run(cli: Cli) -> Result { .combine(Refresh::from(args.settings.upgrade.clone())), ); + // Resolve the build constraints. + let build_constraints = args + .build_constraint + .into_iter() + .map(RequirementsSource::from_constraints_txt) + .collect::>(); + commands::build( args.src, args.package, args.out_dir, args.sdist, args.wheel, + build_constraints, args.python, args.settings, cli.no_config, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 084373c90206..4063d91f005b 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1629,6 +1629,7 @@ pub(crate) struct BuildSettings { pub(crate) out_dir: Option, pub(crate) sdist: bool, pub(crate) wheel: bool, + pub(crate) build_constraint: Vec, pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverSettings, @@ -1643,6 +1644,7 @@ impl BuildSettings { package, sdist, wheel, + build_constraint, python, build, refresh, @@ -1655,6 +1657,10 @@ impl BuildSettings { out_dir, sdist, wheel, + build_constraint: build_constraint + .into_iter() + .filter_map(Maybe::into_option) + .collect(), python, refresh: Refresh::from(refresh), settings: ResolverSettings::combine(resolver_options(resolver, build), filesystem), diff --git a/crates/uv/tests/build.rs b/crates/uv/tests/build.rs index 7c7f52b14182..1eaa5d7a3d90 100644 --- a/crates/uv/tests/build.rs +++ b/crates/uv/tests/build.rs @@ -1146,3 +1146,225 @@ fn workspace() -> Result<()> { Ok(()) } + +#[test] +fn build_constraints() -> Result<()> { + let context = TestContext::new("3.12"); + let filters = context + .filters() + .into_iter() + .chain([ + (r"exit code: 1", "exit status: 1"), + (r"bdist\.[^/\\\s]+-[^/\\\s]+", "bdist.linux-x86_64"), + (r"\\\.", ""), + ]) + .collect::>(); + + let project = context.temp_dir.child("project"); + + let constraints = project.child("constraints.txt"); + constraints.write_str("setuptools==0.1.0")?; + + let pyproject_toml = project.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio==3.7.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + project.child("src").child("__init__.py").touch()?; + project.child("README").touch()?; + + uv_snapshot!(&filters, context.build().arg("--build-constraint").arg("constraints.txt").current_dir(&project), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Building source distribution... + error: Failed to install requirements from `build-system.requires` (resolve) + Caused by: No solution found when resolving: setuptools>=42 + Caused by: Because you require setuptools>=42 and setuptools==0.1.0, we can conclude that your requirements are unsatisfiable. + "###); + + project + .child("dist") + .child("project-0.1.0.tar.gz") + .assert(predicate::path::missing()); + project + .child("dist") + .child("project-0.1.0-py3-none-any.whl") + .assert(predicate::path::missing()); + + Ok(()) +} + +#[test] +fn sha() -> Result<()> { + let context = TestContext::new("3.8"); + let filters = context + .filters() + .into_iter() + .chain([ + (r"exit code: 1", "exit status: 1"), + (r"bdist\.[^/\\\s]+-[^/\\\s]+", "bdist.linux-x86_64"), + (r"\\\.", ""), + ]) + .collect::>(); + + let project = context.temp_dir.child("project"); + + let pyproject_toml = project.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = ["anyio==3.7.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + project.child("src").child("__init__.py").touch()?; + project.child("README").touch()?; + + // Reject an incorrect hash. + let constraints = project.child("constraints.txt"); + constraints.write_str("setuptools==68.2.2 --hash=sha256:a248cb506794bececcddeddb1678bc722f9cfcacf02f98f7c0af6b9ed893caf2")?; + + uv_snapshot!(&filters, context.build().arg("--build-constraint").arg("constraints.txt").current_dir(&project), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Building source distribution... + error: Failed to install requirements from `build-system.requires` (install) + Caused by: Failed to prepare distributions + Caused by: Failed to fetch wheel: setuptools==68.2.2 + Caused by: Hash mismatch for `setuptools==68.2.2` + + Expected: + sha256:a248cb506794bececcddeddb1678bc722f9cfcacf02f98f7c0af6b9ed893caf2 + + Computed: + sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a + "###); + + project + .child("dist") + .child("project-0.1.0.tar.gz") + .assert(predicate::path::missing()); + project + .child("dist") + .child("project-0.1.0-py3-none-any.whl") + .assert(predicate::path::missing()); + + // Accept a correct hash. + let constraints = project.child("constraints.txt"); + constraints.write_str("setuptools==68.2.2 --hash=sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a")?; + + uv_snapshot!(&filters, context.build().arg("--build-constraint").arg("constraints.txt").current_dir(&project), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Building source distribution... + running egg_info + creating src/project.egg-info + writing src/project.egg-info/PKG-INFO + writing dependency_links to src/project.egg-info/dependency_links.txt + writing requirements to src/project.egg-info/requires.txt + writing top-level names to src/project.egg-info/top_level.txt + writing manifest file 'src/project.egg-info/SOURCES.txt' + reading manifest file 'src/project.egg-info/SOURCES.txt' + writing manifest file 'src/project.egg-info/SOURCES.txt' + running sdist + running egg_info + writing src/project.egg-info/PKG-INFO + writing dependency_links to src/project.egg-info/dependency_links.txt + writing requirements to src/project.egg-info/requires.txt + writing top-level names to src/project.egg-info/top_level.txt + reading manifest file 'src/project.egg-info/SOURCES.txt' + writing manifest file 'src/project.egg-info/SOURCES.txt' + running check + creating project-0.1.0 + creating project-0.1.0/src + creating project-0.1.0/src/project.egg-info + copying files to project-0.1.0... + copying README -> project-0.1.0 + copying pyproject.toml -> project-0.1.0 + copying src/__init__.py -> project-0.1.0/src + copying src/project.egg-info/PKG-INFO -> project-0.1.0/src/project.egg-info + copying src/project.egg-info/SOURCES.txt -> project-0.1.0/src/project.egg-info + copying src/project.egg-info/dependency_links.txt -> project-0.1.0/src/project.egg-info + copying src/project.egg-info/requires.txt -> project-0.1.0/src/project.egg-info + copying src/project.egg-info/top_level.txt -> project-0.1.0/src/project.egg-info + Writing project-0.1.0/setup.cfg + Creating tar archive + removing 'project-0.1.0' (and everything under it) + Building wheel from source distribution... + running egg_info + writing src/project.egg-info/PKG-INFO + writing dependency_links to src/project.egg-info/dependency_links.txt + writing requirements to src/project.egg-info/requires.txt + writing top-level names to src/project.egg-info/top_level.txt + reading manifest file 'src/project.egg-info/SOURCES.txt' + writing manifest file 'src/project.egg-info/SOURCES.txt' + running bdist_wheel + running build + running build_py + creating build + creating build/lib + copying src/__init__.py -> build/lib + running egg_info + writing src/project.egg-info/PKG-INFO + writing dependency_links to src/project.egg-info/dependency_links.txt + writing requirements to src/project.egg-info/requires.txt + writing top-level names to src/project.egg-info/top_level.txt + reading manifest file 'src/project.egg-info/SOURCES.txt' + writing manifest file 'src/project.egg-info/SOURCES.txt' + installing to build/bdist.linux-x86_64/wheel + running install + running install_lib + creating build/bdist.linux-x86_64 + creating build/bdist.linux-x86_64/wheel + copying build/lib/__init__.py -> build/bdist.linux-x86_64/wheel + running install_egg_info + Copying src/project.egg-info to build/bdist.linux-x86_64/wheel/project-0.1.0-py3.8.egg-info + running install_scripts + creating build/bdist.linux-x86_64/wheel/project-0.1.0.dist-info/WHEEL + creating '[TEMP_DIR]/project/dist/[TMP]/wheel' to it + adding '__init__.py' + adding 'project-0.1.0.dist-info/METADATA' + adding 'project-0.1.0.dist-info/WHEEL' + adding 'project-0.1.0.dist-info/top_level.txt' + adding 'project-0.1.0.dist-info/RECORD' + removing build/bdist.linux-x86_64/wheel + Successfully built dist/project-0.1.0.tar.gz and dist/project-0.1.0-py3-none-any.whl + "###); + + project + .child("dist") + .child("project-0.1.0.tar.gz") + .assert(predicate::path::is_file()); + project + .child("dist") + .child("project-0.1.0-py3-none-any.whl") + .assert(predicate::path::is_file()); + + Ok(()) +} diff --git a/docs/concepts/projects.md b/docs/concepts/projects.md index c20c1eb20984..d84a363121ea 100644 --- a/docs/concepts/projects.md +++ b/docs/concepts/projects.md @@ -585,6 +585,23 @@ You can limit `uv build` to building a source distribution with `uv build --sour distribution with `uv build --binary`, or build both distributions from source with `uv build --source --binary`. +`uv build` accepts `--build-constraints`, which can be used to constrain the versions of any build +requirements during the build process. When coupled with `--require-hashes`, uv will enforce that +the requirement used to build the project match specific, known hashes, for reproducibility. + +For example, given the following `constraints.txt`: + +```text +setuptools==68.2.2 --hash=sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a +``` + +Running the following would build the project with the specified version of `setuptools`, and verify +that the downloaded `setuptools` distribution matches the specified hash: + +```console +$ uv build --build-constraints constraints.txt --require-hashes +``` + ## Build isolation By default, uv builds all packages in isolated virtual environments, as per diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 85f107e0d86b..5e6839e9123d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -6225,6 +6225,11 @@ uv build [OPTIONS] [SRC]

WARNING: Hosts included in this list will not be verified against the system’s certificate store. Only use --allow-insecure-host in a secure network with verified sources, as it bypasses SSL verification and could expose you to MITM attacks.

May also be set with the UV_INSECURE_HOST environment variable.

+
--build-constraint, -b build-constraint

Constrain build dependencies using the given requirements files when building distributions.

+ +

Constraints files are requirements.txt-like files that only control the version of a build dependency that’s installed. However, including a package in a constraints file will not trigger the inclusion of that package on its own.

+ +

May also be set with the UV_BUILD_CONSTRAINT environment variable.

--cache-dir cache-dir

Path to the cache directory.

Defaults to $HOME/Library/Caches/uv on macOS, $XDG_CACHE_HOME/uv or $HOME/.cache/uv on Linux, and %LOCALAPPDATA%\uv\cache on Windows.