diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 68b4dba9aada..2634187b671f 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2011,9 +2011,18 @@ pub struct BuildArgs { /// directory if no source directory is provided. /// /// If the workspace member does not exist, uv will exit with an error. - #[arg(long)] + #[arg(long, conflicts_with("all"))] pub package: Option, + /// Builds all packages in the workspace. + /// + /// The workspace will be discovered from the provided source directory, or the current + /// directory if no source directory is provided. + /// + /// If the workspace member does not exist, uv will exit with an error. + #[arg(long, conflicts_with("package"))] + pub all: bool, + /// The output directory to which distributions should be written. /// /// Defaults to the `dist` subdirectory within the source directory, or the diff --git a/crates/uv/src/commands/build.rs b/crates/uv/src/commands/build.rs index 1f0ff9e5e6f3..841275d11aca 100644 --- a/crates/uv/src/commands/build.rs +++ b/crates/uv/src/commands/build.rs @@ -4,12 +4,17 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use distribution_filename::SourceDistExtension; +use distribution_types::{DependencyMetadata, IndexLocations}; +use install_wheel_rs::linker::LinkMode; use owo_colors::OwoColorize; 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, HashCheckingMode}; +use uv_configuration::{ + BuildKind, BuildOptions, BuildOutput, Concurrency, ConfigSettings, Constraints, + HashCheckingMode, IndexStrategy, KeyringProviderType, SourceStrategy, TrustedHost, +}; use uv_dispatch::BuildDispatch; use uv_fs::Simplified; use uv_normalize::PackageName; @@ -18,9 +23,9 @@ use uv_python::{ PythonPreference, PythonRequest, PythonVersionFile, VersionRequest, }; use uv_requirements::RequirementsSource; -use uv_resolver::{FlatIndex, RequiresPython}; +use uv_resolver::{ExcludeNewer, FlatIndex, RequiresPython}; use uv_types::{BuildContext, BuildIsolation, HashStrategy}; -use uv_workspace::{DiscoveryOptions, Workspace}; +use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceError}; use crate::commands::pip::operations; use crate::commands::project::find_requires_python; @@ -35,6 +40,7 @@ pub(crate) async fn build( project_dir: &Path, src: Option, package: Option, + all: bool, output_dir: Option, sdist: bool, wheel: bool, @@ -51,10 +57,11 @@ pub(crate) async fn build( cache: &Cache, printer: Printer, ) -> Result { - let assets = build_impl( + let results = build_impl( project_dir, src.as_deref(), package.as_ref(), + all, output_dir.as_deref(), sdist, wheel, @@ -73,32 +80,53 @@ pub(crate) async fn build( ) .await?; - match assets { - BuiltDistributions::Wheel(wheel) => { - writeln!( - printer.stderr(), - "Successfully built {}", - wheel.user_display().bold().cyan() - )?; - } - BuiltDistributions::Sdist(sdist) => { - writeln!( - printer.stderr(), - "Successfully built {}", - sdist.user_display().bold().cyan() - )?; - } - BuiltDistributions::Both(sdist, wheel) => { - writeln!( - printer.stderr(), - "Successfully built {} and {}", - sdist.user_display().bold().cyan(), - wheel.user_display().bold().cyan() - )?; + for result in &results { + match result { + Ok(assets) => match assets { + BuiltDistributions::Wheel(wheel) => { + writeln!( + printer.stderr(), + "Successfully built {}", + wheel.user_display().bold().cyan() + )?; + } + BuiltDistributions::Sdist(sdist) => { + writeln!( + printer.stderr(), + "Successfully built {}", + sdist.user_display().bold().cyan() + )?; + } + BuiltDistributions::Both(sdist, wheel) => { + writeln!( + printer.stderr(), + "Successfully built {} and {}", + sdist.user_display().bold().cyan(), + wheel.user_display().bold().cyan() + )?; + } + }, + Err(err) => { + let mut causes = err.chain(); + writeln!( + printer.stderr(), + "{}: {}", + "error".red().bold(), + causes.next().unwrap() + )?; + + for err in causes { + writeln!(printer.stderr(), " {}: {}", "Caused by".red().bold(), err)?; + } + } } } - Ok(ExitStatus::Success) + if results.iter().any(std::result::Result::is_err) { + Ok(ExitStatus::Error) + } else { + Ok(ExitStatus::Success) + } } #[allow(clippy::fn_params_excessive_bools)] @@ -106,6 +134,7 @@ async fn build_impl( project_dir: &Path, src: Option<&Path>, package: Option<&PackageName>, + all: bool, output_dir: Option<&Path>, sdist: bool, wheel: bool, @@ -121,7 +150,7 @@ async fn build_impl( native_tls: bool, cache: &Cache, printer: Printer, -) -> Result { +) -> Result>> { // Extract the resolver settings. let ResolverSettingsRef { index_locations, @@ -170,8 +199,8 @@ async fn build_impl( // Attempt to discover the workspace; on failure, save the error for later. let workspace = Workspace::discover(src.directory(), &DiscoveryOptions::default()).await; - // If a `--package` was provided, adjust the source directory. - let src = if let Some(package) = package { + // If a `--package` or `--all` was provided, adjust the source directory. + let packages = if let Some(package) = package { if matches!(src, Source::File(_)) { return Err(anyhow::anyhow!( "Cannot specify a `--package` when building from a file" @@ -195,11 +224,101 @@ async fn build_impl( .root() .clone(); - Source::Directory(Cow::Owned(project)) + vec![Source::Directory(Cow::Owned(project))] + } else if all { + if matches!(src, Source::File(_)) { + return Err(anyhow::anyhow!( + "Cannot specify a `--all` when building from a file" + )); + } + let workspace = match workspace { + Ok(ref workspace) => workspace, + Err(err) => { + return Err( + anyhow::anyhow!("`--all` was provided, but no workspace was found") + .context(err), + ) + } + }; + workspace + .packages() + .values() + .map(|package| Source::Directory(Cow::Owned(package.root().clone()))) + .collect() } else { - src + vec![src] }; + let build_futures = packages.iter().map(|src| { + build_package( + src, + output_dir, + python_request, + no_config, + workspace.as_ref(), + python_preference, + python_downloads, + cache, + printer, + index_locations, + &client_builder, + hash_checking, + build_constraints, + no_build_isolation, + no_build_isolation_package, + native_tls, + connectivity, + index_strategy, + keyring_provider, + allow_insecure_host, + exclude_newer, + sources, + concurrency, + build_options, + sdist, + wheel, + dependency_metadata, + link_mode, + config_setting, + ) + }); + + let results = futures::future::join_all(build_futures).await; + Ok(results) +} + +#[allow(clippy::fn_params_excessive_bools)] +async fn build_package( + src: &Source<'_>, + output_dir: Option<&Path>, + python_request: Option<&str>, + no_config: bool, + workspace: Result<&Workspace, &WorkspaceError>, + python_preference: PythonPreference, + python_downloads: PythonDownloads, + cache: &Cache, + printer: Printer, + index_locations: &IndexLocations, + client_builder: &BaseClientBuilder<'_>, + hash_checking: Option, + build_constraints: &[RequirementsSource], + no_build_isolation: bool, + no_build_isolation_package: &[PackageName], + native_tls: bool, + connectivity: Connectivity, + index_strategy: IndexStrategy, + keyring_provider: KeyringProviderType, + allow_insecure_host: &[TrustedHost], + exclude_newer: Option, + sources: SourceStrategy, + concurrency: Concurrency, + build_options: &BuildOptions, + sdist: bool, + wheel: bool, + dependency_metadata: &DependencyMetadata, + link_mode: LinkMode, + config_setting: &ConfigSettings, +) -> Result { let output_dir = if let Some(output_dir) = output_dir { Cow::Owned(std::path::absolute(output_dir)?) } else { @@ -221,7 +340,7 @@ async fn build_impl( // (3) `Requires-Python` in `pyproject.toml` if interpreter_request.is_none() { - if let Ok(ref workspace) = workspace { + if let Ok(workspace) = workspace { interpreter_request = find_requires_python(workspace)? .as_ref() .map(RequiresPython::specifiers) @@ -237,7 +356,7 @@ async fn build_impl( EnvironmentPreference::Any, python_preference, python_downloads, - &client_builder, + client_builder, cache, Some(&PythonDownloadReporter::single(printer)), ) @@ -250,8 +369,7 @@ async fn build_impl( } // Read build constraints. - let build_constraints = - operations::read_constraints(build_constraints, &client_builder).await?; + let build_constraints = operations::read_constraints(build_constraints, client_builder).await?; // Collect the set of required hashes. let hasher = if let Some(hash_checking) = hash_checking { diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 983bc3c2ba3b..f675bf02881a 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -683,6 +683,7 @@ async fn run(cli: Cli) -> Result { &project_dir, args.src, args.package, + args.all, args.out_dir, args.sdist, args.wheel, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index fb924949e161..416ccce5a5dc 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1671,6 +1671,7 @@ impl PipCheckSettings { pub(crate) struct BuildSettings { pub(crate) src: Option, pub(crate) package: Option, + pub(crate) all: bool, pub(crate) out_dir: Option, pub(crate) sdist: bool, pub(crate) wheel: bool, @@ -1688,6 +1689,7 @@ impl BuildSettings { src, out_dir, package, + all, sdist, wheel, build_constraint, @@ -1704,6 +1706,7 @@ impl BuildSettings { Self { src, package, + all, out_dir, sdist, wheel, diff --git a/crates/uv/tests/build.rs b/crates/uv/tests/build.rs index 31110be6cbcb..e107f17ae1bc 100644 --- a/crates/uv/tests/build.rs +++ b/crates/uv/tests/build.rs @@ -1042,6 +1042,32 @@ fn workspace() -> Result<()> { .child("member-0.1.0-py3-none-any.whl") .assert(predicate::path::is_file()); + // Build all packages. + uv_snapshot!(&filters, context.build().arg("--all").arg("--quiet").current_dir(&project), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + "###); + + member + .child("dist") + .child("member-0.1.0.tar.gz") + .assert(predicate::path::is_file()); + member + .child("dist") + .child("member-0.1.0-py3-none-any.whl") + .assert(predicate::path::is_file()); + 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()); + // If a source is provided, discover the workspace from the source. uv_snapshot!(&filters, context.build().arg("./project").arg("--package").arg("member"), @r###" success: true @@ -1123,6 +1149,15 @@ fn workspace() -> Result<()> { Successfully built project/packages/member/dist/member-0.1.0.tar.gz and project/packages/member/dist/member-0.1.0-py3-none-any.whl "###); + // If a source is provided, discover the workspace from the source. + uv_snapshot!(&filters, context.build().arg("./project").arg("--all").arg("--quiet"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + "###); + // Fail when `--package` is provided without a workspace. uv_snapshot!(&filters, context.build().arg("--package").arg("member"), @r###" success: false @@ -1134,6 +1169,17 @@ fn workspace() -> Result<()> { Caused by: `--package` was provided, but no workspace was found "###); + // Fail when `--all` is provided without a workspace. + uv_snapshot!(&filters, context.build().arg("--all"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `pyproject.toml` found in current directory or any parent directory + Caused by: `--all` was provided, but no workspace was found + "###); + // Fail when `--package` is a non-existent member without a workspace. uv_snapshot!(&filters, context.build().arg("--package").arg("fail").current_dir(&project), @r###" success: false @@ -1147,6 +1193,127 @@ fn workspace() -> Result<()> { Ok(()) } +#[test] +fn build_all_with_failure() -> 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 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"] + + [tool.uv.workspace] + members = ["packages/*"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + project.child("src").child("__init__.py").touch()?; + project.child("README").touch()?; + + let member_a = project.child("packages").child("member_a"); + fs_err::create_dir_all(member_a.path())?; + + let member_b = project.child("packages").child("member_b"); + fs_err::create_dir_all(member_b.path())?; + + member_a.child("pyproject.toml").write_str( + r#" + [project] + name = "member_a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + member_a.child("src").child("__init__.py").touch()?; + member_a.child("README").touch()?; + + member_b.child("pyproject.toml").write_str( + r#" + [project] + name = "member_b" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + member_b.child("src").child("__init__.py").touch()?; + member_b.child("README").touch()?; + + // member_b build should fail + member_b.child("setup.py").write_str( + r#" + from setuptools import setup + + setup( + name="project", + version="0.1.0", + packages=["project"], + install_requires=["foo==3.7.0"], + ) + "#, + )?; + + // Build all the packages + uv_snapshot!(&filters, context.build().arg("--all").arg("--quiet").current_dir(&project), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + "###); + + // project and member_a should be built, regardless of member_b build failure + 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()); + + member_a + .child("dist") + .child("member_a-0.1.0.tar.gz") + .assert(predicate::path::is_file()); + member_a + .child("dist") + .child("member_a-0.1.0-py3-none-any.whl") + .assert(predicate::path::is_file()); + + Ok(()) +} + #[test] fn build_constraints() -> Result<()> { let context = TestContext::new("3.12"); diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 221f35f6599b..2a7f325dfed8 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -6778,7 +6778,13 @@ uv build [OPTIONS] [SRC]

Options

-
--allow-insecure-host allow-insecure-host

Allow insecure connections to a host.

+
--all

Builds all packages in the workspace.

+ +

The workspace will be discovered from the provided source directory, or the current directory if no source directory is provided.

+ +

If the workspace member does not exist, uv will exit with an error.

+ +
--allow-insecure-host allow-insecure-host

Allow insecure connections to a host.

Can be provided multiple times.