Skip to content

Commit

Permalink
Support uv build --wheel from source distributions
Browse files Browse the repository at this point in the history
  • Loading branch information
charliermarsh committed Sep 2, 2024
1 parent 34fea79 commit 47e92e6
Show file tree
Hide file tree
Showing 4 changed files with 241 additions and 16 deletions.
17 changes: 12 additions & 5 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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)]
Expand All @@ -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,

Expand Down
86 changes: 78 additions & 8 deletions crates/uv/src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use uv_cache::Cache;
use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{BuildKind, Concurrency};
use uv_dispatch::BuildDispatch;
use uv_fs::CWD;
use uv_fs::{Simplified, CWD};
use uv_python::{
EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonPreference, PythonRequest, PythonVersionFile, VersionRequest,
Expand Down Expand Up @@ -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.user_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.
Expand All @@ -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.user_display())
})?;
let temp_dir = tempfile::tempdir_in(&output_dir)?;
uv_extract::stream::archive(reader, ext, temp_dir.path()).await?;

Expand Down Expand Up @@ -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.user_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)
Expand Down Expand Up @@ -335,4 +402,7 @@ enum BuildPlan {

/// Build a source distribution and a wheel from source.
SdistAndWheel,

/// Build a wheel from a source distribution.
WheelFromSdist,
}
144 changes: 144 additions & 0 deletions crates/uv/tests/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use anyhow::Result;
use assert_fs::prelude::*;
use common::{uv_snapshot, TestContext};
use predicates::prelude::predicate;

mod common;

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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(())
}
10 changes: 7 additions & 3 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<h3 class="cli-reference">Usage</h3>

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

<h3 class="cli-reference">Arguments</h3>

<dl class="cli-reference"><dt><code>SRC_DIR</code></dt><dd><p>The directory from which source distributions and/or wheels should be built.</p>
<dl class="cli-reference"><dt><code>SRC_DIR</code></dt><dd><p>The directory from which source distributions and/or wheels should be built, or a source distribution archive to build into a wheel.</p>

<p>Defaults to the current working directory.</p>

Expand Down Expand Up @@ -6028,7 +6032,7 @@ uv build [OPTIONS] [SRC_DIR]

</dd><dt><code>--version</code>, <code>-V</code></dt><dd><p>Display the uv version</p>

</dd><dt><code>--wheel</code></dt><dd><p>Build a built distribution (&quot;wheel&quot;) from the given directory</p>
</dd><dt><code>--wheel</code></dt><dd><p>Build a binary distribution (&quot;wheel&quot;) from the given directory</p>

</dd></dl>

Expand Down

0 comments on commit 47e92e6

Please sign in to comment.