diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index ca02ce0685f5e..6387fa295ba4e 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -337,14 +337,20 @@ pub enum Commands { Venv(VenvArgs), /// Build Python packages into source distributions and wheels. /// - /// By default, `uv build` will build a source distribution ("sdist") - /// from the source directory, and a binary distribution ("wheel") from - /// the source distribution. + /// `uv build` accepts a path to a directory or source distribution, + /// which defaults to the current working directory. + /// + /// By default, if passed a directory, `uv build` will build a source + /// distribution ("sdist") from the source directory, and a binary + /// distribution ("wheel") from the source distribution. /// /// `uv build --sdist` can be used to build only the source distribution, /// `uv build --wheel` can be used to build only the binary distribution, /// and `uv build --sdist --wheel` can be used to build both distributions /// from source. + /// + /// If passed a source distribution, `uv build --wheel` will build a wheel + /// from the source distribution. #[command( after_help = "Use `uv help build` for more details.", after_long_help = "" @@ -1941,7 +1947,8 @@ pub struct PipTreeArgs { #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct BuildArgs { - /// The directory from which source distributions and/or wheels should be built. + /// The directory from which source distributions and/or wheels should be built, or a source + /// distribution archive to build into a wheel. /// /// Defaults to the current working directory. #[arg(value_parser = parse_file_path)] @@ -1951,7 +1958,7 @@ pub struct BuildArgs { #[arg(long)] pub sdist: bool, - /// Build a built distribution ("wheel") from the given directory. + /// Build a binary distribution ("wheel") from the given directory. #[arg(long)] pub wheel: bool, diff --git a/crates/uv/src/commands/build.rs b/crates/uv/src/commands/build.rs index e905e17994cc5..d4effd19d2088 100644 --- a/crates/uv/src/commands/build.rs +++ b/crates/uv/src/commands/build.rs @@ -227,14 +227,49 @@ async fn build_impl( // Determine the build plan from the command-line arguments. let path = path.unwrap_or_else(|| &*CWD); - let output_dir = path.join("dist"); + let metadata = match fs_err::tokio::metadata(path).await { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(anyhow::anyhow!( + "Source `{}` does not exist", + path.simplified_display() + )); + } + Err(err) => return Err(err.into()), + }; + + // Create the output directory. + let output_dir = if metadata.is_dir() { + path.join("dist") + } else { + path.parent().unwrap().to_path_buf() + }; fs_err::tokio::create_dir_all(&output_dir).await?; - let plan = match (sdist, wheel) { - (false, false) => BuildPlan::SdistToWheel, - (true, false) => BuildPlan::Sdist, - (false, true) => BuildPlan::Wheel, - (true, true) => BuildPlan::SdistAndWheel, + // Determine the build plan. + let plan = if metadata.is_dir() { + // We're building from a directory. + match (sdist, wheel) { + (false, false) => BuildPlan::SdistToWheel, + (false, true) => BuildPlan::Wheel, + (true, false) => BuildPlan::Sdist, + (true, true) => BuildPlan::SdistAndWheel, + } + } else { + // We're building from a file, which must be a source distribution. + match (sdist, wheel) { + (false, true) => BuildPlan::WheelFromSdist, + (false, false) => { + return Err(anyhow::anyhow!( + "Pass `--wheel` explicitly to build a wheel from a source distribution" + )); + } + (true, _) => { + return Err(anyhow::anyhow!( + "Building an `--sdist` from a source distribution is not supported" + )); + } + } }; // Prepare some common arguments for the build. @@ -253,7 +288,9 @@ async fn build_impl( // Extract the source distribution into a temporary directory. let path = output_dir.join(&sdist); let reader = fs_err::tokio::File::open(&path).await?; - let ext = SourceDistExtension::from_path(&path)?; + let ext = SourceDistExtension::from_path(path.as_path()).map_err(|err| { + anyhow::anyhow!("`{}` is not a valid source distribution, as it ends with an unsupported extension. Expected one of: {err}.", path.simplified_display()) + })?; let temp_dir = tempfile::tempdir_in(&output_dir)?; uv_extract::stream::archive(reader, ext, temp_dir.path()).await?; @@ -307,6 +344,36 @@ async fn build_impl( BuiltDistributions::Both(sdist, wheel) } + BuildPlan::WheelFromSdist => { + // Extract the source distribution into a temporary directory. + let reader = fs_err::tokio::File::open(path).await?; + let ext = SourceDistExtension::from_path(path).map_err(|err| { + anyhow::anyhow!("`{}` is not a valid build source. Expected to receive a source directory, or a source distribution ending in one of: {err}.", path.simplified_display()) + })?; + let temp_dir = tempfile::tempdir_in(&output_dir)?; + uv_extract::stream::archive(reader, ext, temp_dir.path()).await?; + + // Extract the top-level directory from the archive. + let extracted = match uv_extract::strip_component(temp_dir.path()) { + Ok(top_level) => top_level, + Err(uv_extract::Error::NonSingularArchive(_)) => temp_dir.path().to_path_buf(), + Err(err) => return Err(err.into()), + }; + + // Build a wheel from the source distribution. + let builder = build_dispatch + .setup_build( + &extracted, + subdirectory, + &version_id, + dist, + BuildKind::Wheel, + ) + .await?; + let wheel = builder.build(&output_dir).await?; + + BuiltDistributions::Wheel(wheel) + } }; Ok(assets) @@ -335,4 +402,7 @@ enum BuildPlan { /// Build a source distribution and a wheel from source. SdistAndWheel, + + /// Build a wheel from a source distribution. + WheelFromSdist, } diff --git a/crates/uv/tests/build.rs b/crates/uv/tests/build.rs index 9c5da94c48f07..0b25b70cace5e 100644 --- a/crates/uv/tests/build.rs +++ b/crates/uv/tests/build.rs @@ -3,6 +3,7 @@ use anyhow::Result; use assert_fs::prelude::*; use common::{uv_snapshot, TestContext}; +use predicates::prelude::predicate; mod common; @@ -39,6 +40,17 @@ fn build() -> Result<()> { Successfully built project-0.1.0.tar.gz and 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()); + + fs_err::remove_dir_all(project.child("dist"))?; + // Build the current working directory. uv_snapshot!(context.filters(), context.build().current_dir(project.path()), @r###" success: true @@ -49,6 +61,17 @@ fn build() -> Result<()> { Successfully built project-0.1.0.tar.gz and 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()); + + fs_err::remove_dir_all(project.child("dist"))?; + // Error if there's nothing to build. uv_snapshot!(context.filters(), context.build(), @r###" success: false @@ -95,6 +118,15 @@ fn sdist() -> Result<()> { Successfully built project-0.1.0.tar.gz "###); + 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::missing()); + Ok(()) } @@ -131,6 +163,15 @@ fn wheel() -> Result<()> { Successfully built project-0.1.0-py3-none-any.whl "###); + 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::is_file()); + Ok(()) } @@ -167,5 +208,108 @@ fn sdist_wheel() -> Result<()> { Successfully built project-0.1.0.tar.gz and 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(()) +} + +#[test] +fn wheel_from_sdist() -> Result<()> { + let context = TestContext::new("3.12"); + + 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"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + project.child("src").child("__init__.py").touch()?; + + // Build the sdist. + uv_snapshot!(context.filters(), context.build().arg("--sdist").current_dir(&project), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Successfully built project-0.1.0.tar.gz + "###); + + 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::missing()); + + // Error if `--wheel` is not specified. + uv_snapshot!(context.filters(), context.build().arg("./dist/project-0.1.0.tar.gz").current_dir(&project), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Pass `--wheel` explicitly to build a wheel from a source distribution + "###); + + // Error if `--sdist` is not specified. + uv_snapshot!(context.filters(), context.build().arg("./dist/project-0.1.0.tar.gz").arg("--sdist").current_dir(&project), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Building an `--sdist` from a source distribution is not supported + "###); + + // Build the wheel from the sdist. + uv_snapshot!(context.filters(), context.build().arg("./dist/project-0.1.0.tar.gz").arg("--wheel").current_dir(&project), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Successfully built 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()); + + // Passing a wheel is an error. + uv_snapshot!(context.filters(), context.build().arg("./dist/project-0.1.0-py3-none-any.whl").arg("--wheel").current_dir(&project), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: `./dist/project-0.1.0-py3-none-any.whl` is not a valid build source. Expected to receive a source directory, or a source distribution ending in one of: `.zip`, `.tar.gz`, `.tar.bz2`, `.tar.xz`, or `.tar.zst`. + "###); + Ok(()) } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index dc651b995c963..3de80f9bcfd6d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -5796,10 +5796,14 @@ uv venv [OPTIONS] [NAME] Build Python packages into source distributions and wheels. -By default, `uv build` will build a source distribution ("sdist") from the source directory, and a binary distribution ("wheel") from the source distribution. +`uv build` accepts a path to a directory or source distribution, which defaults to the current working directory. + +By default, if passed a directory, `uv build` will build a source distribution ("sdist") from the source directory, and a binary distribution ("wheel") from the source distribution. `uv build --sdist` can be used to build only the source distribution, `uv build --wheel` can be used to build only the binary distribution, and `uv build --sdist --wheel` can be used to build both distributions from source. +If passed a source distribution, `uv build --wheel` will build a wheel from the source distribution. +

Usage

``` @@ -5808,7 +5812,7 @@ uv build [OPTIONS] [SRC_DIR]

Arguments

-
SRC_DIR

The directory from which source distributions and/or wheels should be built.

+
SRC_DIR

The directory from which source distributions and/or wheels should be built, or a source distribution archive to build into a wheel.

Defaults to the current working directory.

@@ -6028,7 +6032,7 @@ uv build [OPTIONS] [SRC_DIR]
--version, -V

Display the uv version

-
--wheel

Build a built distribution ("wheel") from the given directory

+
--wheel

Build a binary distribution ("wheel") from the given directory