diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 61738a4934ca..d2fc3f165780 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1955,6 +1955,15 @@ pub struct BuildArgs { #[arg(value_parser = parse_file_path)] pub src: Option, + /// Build a specific package 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)] + pub package: Option, + /// 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 7b7d2f6bd621..f66331126f5c 100644 --- a/crates/uv/src/commands/build.rs +++ b/crates/uv/src/commands/build.rs @@ -15,19 +15,20 @@ use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClient use uv_configuration::{BuildKind, BuildOutput, Concurrency}; use uv_dispatch::BuildDispatch; use uv_fs::{Simplified, CWD}; +use uv_normalize::PackageName; use uv_python::{ EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile, VersionRequest, }; use uv_resolver::{FlatIndex, RequiresPython}; use uv_types::{BuildContext, BuildIsolation, HashStrategy}; -use uv_warnings::warn_user_once; -use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceError}; +use uv_workspace::{DiscoveryOptions, Workspace}; /// Build source distributions and wheels. #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn build( src: Option, + package: Option, output_dir: Option, sdist: bool, wheel: bool, @@ -44,6 +45,7 @@ pub(crate) async fn build( ) -> Result { let assets = build_impl( src.as_deref(), + package.as_ref(), output_dir.as_deref(), sdist, wheel, @@ -82,6 +84,7 @@ pub(crate) async fn build( #[allow(clippy::fn_params_excessive_bools)] async fn build_impl( src: Option<&Path>, + package: Option<&PackageName>, output_dir: Option<&Path>, sdist: bool, wheel: bool, @@ -118,6 +121,7 @@ async fn build_impl( .connectivity(connectivity) .native_tls(native_tls); + // Determine the source to build. let src = if let Some(src) = src { let src = std::path::absolute(src)?; let metadata = match fs_err::tokio::metadata(&src).await { @@ -139,9 +143,37 @@ async fn build_impl( Source::Directory(Cow::Borrowed(&*CWD)) }; - let src_dir = match src { - Source::Directory(ref src) => src, - Source::File(ref src) => src.parent().unwrap(), + // 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 matches!(src, Source::File(_)) { + return Err(anyhow::anyhow!( + "Cannot specify a `--package` when building from a file" + )); + } + + let workspace = match workspace { + Ok(ref workspace) => workspace, + Err(err) => { + return Err( + anyhow::anyhow!("`--package` was provided, but no workspace was found") + .context(err), + ) + } + }; + + let project = workspace + .packages() + .get(package) + .ok_or_else(|| anyhow::anyhow!("Package `{}` not found in workspace", package))? + .root() + .clone(); + + Source::Directory(Cow::Owned(project)) + } else { + src }; let output_dir = if let Some(output_dir) = output_dir { @@ -158,26 +190,15 @@ async fn build_impl( // (2) Request from `.python-version` if interpreter_request.is_none() { - interpreter_request = PythonVersionFile::discover(&src_dir, no_config, false) + interpreter_request = PythonVersionFile::discover(src.directory(), no_config, false) .await? .and_then(PythonVersionFile::into_version); } // (3) `Requires-Python` in `pyproject.toml` if interpreter_request.is_none() { - let project = match VirtualProject::discover(src_dir, &DiscoveryOptions::default()).await { - Ok(project) => Some(project), - Err(WorkspaceError::MissingProject(_)) => None, - Err(WorkspaceError::MissingPyprojectToml) => None, - Err(WorkspaceError::NonWorkspace(_)) => None, - Err(err) => { - warn_user_once!("{err}"); - None - } - }; - - if let Some(project) = project { - interpreter_request = find_requires_python(project.workspace())? + if let Ok(ref workspace) = workspace { + interpreter_request = find_requires_python(workspace)? .as_ref() .map(RequiresPython::specifiers) .map(|specifiers| { @@ -463,8 +484,15 @@ enum Source<'a> { impl<'a> Source<'a> { fn path(&self) -> &Path { match self { - Source::File(path) => path.as_ref(), - Source::Directory(path) => path.as_ref(), + Self::File(path) => path.as_ref(), + Self::Directory(path) => path.as_ref(), + } + } + + fn directory(&self) -> &Path { + match self { + Self::File(path) => path.parent().unwrap(), + Self::Directory(path) => path, } } } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 079e3d2e67d3..eb331b996128 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -672,6 +672,7 @@ async fn run(cli: Cli) -> Result { commands::build( args.src, + args.package, args.out_dir, args.sdist, args.wheel, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index f8f6f06b4116..86941d507046 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1616,6 +1616,7 @@ impl PipCheckSettings { #[derive(Debug, Clone)] pub(crate) struct BuildSettings { pub(crate) src: Option, + pub(crate) package: Option, pub(crate) out_dir: Option, pub(crate) sdist: bool, pub(crate) wheel: bool, @@ -1630,6 +1631,7 @@ impl BuildSettings { let BuildArgs { src, out_dir, + package, sdist, wheel, python, @@ -1640,6 +1642,7 @@ impl BuildSettings { Self { src, + package, out_dir, sdist, wheel, diff --git a/crates/uv/tests/build.rs b/crates/uv/tests/build.rs index 05c88acbd575..7fd8483c8282 100644 --- a/crates/uv/tests/build.rs +++ b/crates/uv/tests/build.rs @@ -893,3 +893,256 @@ fn fail() -> Result<()> { Ok(()) } + +#[test] +fn workspace() -> 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 = 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" + dependencies = ["iniconfig"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + member.child("src").child("__init__.py").touch()?; + member.child("README").touch()?; + + // Build the member. + uv_snapshot!(&filters, context.build().arg("--package").arg("member").current_dir(&project), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Building source distribution... + running egg_info + creating src/member.egg-info + writing src/member.egg-info/PKG-INFO + writing dependency_links to src/member.egg-info/dependency_links.txt + writing requirements to src/member.egg-info/requires.txt + writing top-level names to src/member.egg-info/top_level.txt + writing manifest file 'src/member.egg-info/SOURCES.txt' + reading manifest file 'src/member.egg-info/SOURCES.txt' + writing manifest file 'src/member.egg-info/SOURCES.txt' + running sdist + running egg_info + writing src/member.egg-info/PKG-INFO + writing dependency_links to src/member.egg-info/dependency_links.txt + writing requirements to src/member.egg-info/requires.txt + writing top-level names to src/member.egg-info/top_level.txt + reading manifest file 'src/member.egg-info/SOURCES.txt' + writing manifest file 'src/member.egg-info/SOURCES.txt' + running check + creating member-0.1.0 + creating member-0.1.0/src + creating member-0.1.0/src/member.egg-info + copying files to member-0.1.0... + copying README -> member-0.1.0 + copying pyproject.toml -> member-0.1.0 + copying src/__init__.py -> member-0.1.0/src + copying src/member.egg-info/PKG-INFO -> member-0.1.0/src/member.egg-info + copying src/member.egg-info/SOURCES.txt -> member-0.1.0/src/member.egg-info + copying src/member.egg-info/dependency_links.txt -> member-0.1.0/src/member.egg-info + copying src/member.egg-info/requires.txt -> member-0.1.0/src/member.egg-info + copying src/member.egg-info/top_level.txt -> member-0.1.0/src/member.egg-info + copying src/member.egg-info/SOURCES.txt -> member-0.1.0/src/member.egg-info + Writing member-0.1.0/setup.cfg + Creating tar archive + removing 'member-0.1.0' (and everything under it) + Building wheel from source distribution... + running egg_info + writing src/member.egg-info/PKG-INFO + writing dependency_links to src/member.egg-info/dependency_links.txt + writing requirements to src/member.egg-info/requires.txt + writing top-level names to src/member.egg-info/top_level.txt + reading manifest file 'src/member.egg-info/SOURCES.txt' + writing manifest file 'src/member.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/member.egg-info/PKG-INFO + writing dependency_links to src/member.egg-info/dependency_links.txt + writing requirements to src/member.egg-info/requires.txt + writing top-level names to src/member.egg-info/top_level.txt + reading manifest file 'src/member.egg-info/SOURCES.txt' + writing manifest file 'src/member.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/member.egg-info to build/bdist.linux-x86_64/wheel/member-0.1.0-py3.12.egg-info + running install_scripts + creating build/bdist.linux-x86_64/wheel/member-0.1.0.dist-info/WHEEL + creating '[TEMP_DIR]/project/packages/member/dist/[TMP]/wheel' to it + adding '__init__.py' + adding 'member-0.1.0.dist-info/METADATA' + adding 'member-0.1.0.dist-info/WHEEL' + adding 'member-0.1.0.dist-info/top_level.txt' + adding 'member-0.1.0.dist-info/RECORD' + removing build/bdist.linux-x86_64/wheel + Successfully built packages/member/dist/member-0.1.0.tar.gz and packages/member/dist/member-0.1.0-py3-none-any.whl + "###); + + 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()); + + // 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 + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Building source distribution... + running egg_info + writing src/member.egg-info/PKG-INFO + writing dependency_links to src/member.egg-info/dependency_links.txt + writing requirements to src/member.egg-info/requires.txt + writing top-level names to src/member.egg-info/top_level.txt + reading manifest file 'src/member.egg-info/SOURCES.txt' + writing manifest file 'src/member.egg-info/SOURCES.txt' + running sdist + running egg_info + writing src/member.egg-info/PKG-INFO + writing dependency_links to src/member.egg-info/dependency_links.txt + writing requirements to src/member.egg-info/requires.txt + writing top-level names to src/member.egg-info/top_level.txt + reading manifest file 'src/member.egg-info/SOURCES.txt' + writing manifest file 'src/member.egg-info/SOURCES.txt' + running check + creating member-0.1.0 + creating member-0.1.0/src + creating member-0.1.0/src/member.egg-info + copying files to member-0.1.0... + copying README -> member-0.1.0 + copying pyproject.toml -> member-0.1.0 + copying src/__init__.py -> member-0.1.0/src + copying src/member.egg-info/PKG-INFO -> member-0.1.0/src/member.egg-info + copying src/member.egg-info/SOURCES.txt -> member-0.1.0/src/member.egg-info + copying src/member.egg-info/dependency_links.txt -> member-0.1.0/src/member.egg-info + copying src/member.egg-info/requires.txt -> member-0.1.0/src/member.egg-info + copying src/member.egg-info/top_level.txt -> member-0.1.0/src/member.egg-info + copying src/member.egg-info/SOURCES.txt -> member-0.1.0/src/member.egg-info + Writing member-0.1.0/setup.cfg + Creating tar archive + removing 'member-0.1.0' (and everything under it) + Building wheel from source distribution... + running egg_info + writing src/member.egg-info/PKG-INFO + writing dependency_links to src/member.egg-info/dependency_links.txt + writing requirements to src/member.egg-info/requires.txt + writing top-level names to src/member.egg-info/top_level.txt + reading manifest file 'src/member.egg-info/SOURCES.txt' + writing manifest file 'src/member.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/member.egg-info/PKG-INFO + writing dependency_links to src/member.egg-info/dependency_links.txt + writing requirements to src/member.egg-info/requires.txt + writing top-level names to src/member.egg-info/top_level.txt + reading manifest file 'src/member.egg-info/SOURCES.txt' + writing manifest file 'src/member.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/member.egg-info to build/bdist.linux-x86_64/wheel/member-0.1.0-py3.12.egg-info + running install_scripts + creating build/bdist.linux-x86_64/wheel/member-0.1.0.dist-info/WHEEL + creating '[TEMP_DIR]/project/packages/member/dist/[TMP]/wheel' to it + adding '__init__.py' + adding 'member-0.1.0.dist-info/METADATA' + adding 'member-0.1.0.dist-info/WHEEL' + adding 'member-0.1.0.dist-info/top_level.txt' + adding 'member-0.1.0.dist-info/RECORD' + removing build/bdist.linux-x86_64/wheel + 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 + "###); + + // Fail when `--package` is provided without a workspace. + uv_snapshot!(&filters, context.build().arg("--package").arg("member"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `pyproject.toml` found in current directory or any parent directory + Caused by: `--package` 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 + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Package `fail` not found in workspace + "###); + + Ok(()) +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 63ad47feb5ed..55dccf1dd89a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -6374,6 +6374,12 @@ uv build [OPTIONS] [SRC]

Defaults to the dist subdirectory within the source directory, or the directory containing the source distribution archive.

+
--package package

Build a specific package 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.

+
--prerelease prerelease

The strategy to use when considering pre-release versions.

By default, uv will accept pre-releases for packages that only publish pre-releases, along with first-party requirements that contain an explicit pre-release marker in the declared specifiers (if-necessary-or-explicit).