Add --package support to uv build (#6990)

## Summary

This PR adds `--package` support to `uv build`, such that you can use
`--package` from anywhere in a workspace to build any member.

If a source directory is provided, we use that as the workspace root.

If a file is provided, we error.

For now, `uv build` only builds the current package, making it
semantically identical to `uv sync`.
This commit is contained in:
Charlie Marsh 2024-09-04 11:52:21 -04:00 committed by GitHub
parent 05ed4bc11d
commit 7aed94bed2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 321 additions and 21 deletions

View file

@ -1955,6 +1955,15 @@ pub struct BuildArgs {
#[arg(value_parser = parse_file_path)]
pub src: Option<PathBuf>,
/// 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<PackageName>,
/// The output directory to which distributions should be written.
///
/// Defaults to the `dist` subdirectory within the source directory, or the

View file

@ -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<PathBuf>,
package: Option<PackageName>,
output_dir: Option<PathBuf>,
sdist: bool,
wheel: bool,
@ -44,6 +45,7 @@ pub(crate) async fn build(
) -> Result<ExitStatus> {
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,
}
}
}

View file

@ -672,6 +672,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
commands::build(
args.src,
args.package,
args.out_dir,
args.sdist,
args.wheel,

View file

@ -1616,6 +1616,7 @@ impl PipCheckSettings {
#[derive(Debug, Clone)]
pub(crate) struct BuildSettings {
pub(crate) src: Option<PathBuf>,
pub(crate) package: Option<PackageName>,
pub(crate) out_dir: Option<PathBuf>,
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,

View file

@ -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::<Vec<_>>();
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(())
}

View file

@ -6374,6 +6374,12 @@ uv build [OPTIONS] [SRC]
<p>Defaults to the <code>dist</code> subdirectory within the source directory, or the directory containing the source distribution archive.</p>
</dd><dt><code>--package</code> <i>package</i></dt><dd><p>Build a specific package in the workspace.</p>
<p>The workspace will be discovered from the provided source directory, or the current directory if no source directory is provided.</p>
<p>If the workspace member does not exist, uv will exit with an error.</p>
</dd><dt><code>--prerelease</code> <i>prerelease</i></dt><dd><p>The strategy to use when considering pre-release versions.</p>
<p>By default, uv will accept pre-releases for packages that <em>only</em> publish pre-releases, along with first-party requirements that contain an explicit pre-release marker in the declared specifiers (<code>if-necessary-or-explicit</code>).</p>