Skip to content

Commit 5873c77

Browse files
committed
Support uv build --wheel from source distributions
1 parent 6933f30 commit 5873c77

File tree

4 files changed

+241
-15
lines changed

4 files changed

+241
-15
lines changed

crates/uv-cli/src/lib.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -337,14 +337,20 @@ pub enum Commands {
337337
Venv(VenvArgs),
338338
/// Build Python packages into source distributions and wheels.
339339
///
340-
/// By default, `uv build` will build a source distribution ("sdist")
341-
/// from the source directory, and a binary distribution ("wheel") from
342-
/// the source distribution.
340+
/// `uv build` accepts a path to a directory or source distribution,
341+
/// which defaults to the current working directory.
342+
///
343+
/// By default, if passed a directory, `uv build` will build a source
344+
/// distribution ("sdist") from the source directory, and a binary
345+
/// distribution ("wheel") from the source distribution.
343346
///
344347
/// `uv build --sdist` can be used to build only the source distribution,
345348
/// `uv build --wheel` can be used to build only the binary distribution,
346349
/// and `uv build --sdist --wheel` can be used to build both distributions
347350
/// from source.
351+
///
352+
/// If passed a source distribution, `uv build --wheel` will build a wheel
353+
/// from the source distribution.
348354
#[command(
349355
after_help = "Use `uv help build` for more details.",
350356
after_long_help = ""
@@ -1941,7 +1947,8 @@ pub struct PipTreeArgs {
19411947
#[derive(Args)]
19421948
#[allow(clippy::struct_excessive_bools)]
19431949
pub struct BuildArgs {
1944-
/// The directory from which source distributions and/or wheels should be built.
1950+
/// The directory from which source distributions and/or wheels should be built, or a source
1951+
/// distribution archive to build into a wheel.
19451952
///
19461953
/// Defaults to the current working directory.
19471954
#[arg(value_parser = parse_file_path)]
@@ -1951,7 +1958,7 @@ pub struct BuildArgs {
19511958
#[arg(long)]
19521959
pub sdist: bool,
19531960

1954-
/// Build a built distribution ("wheel") from the given directory.
1961+
/// Build a binary distribution ("wheel") from the given directory.
19551962
#[arg(long)]
19561963
pub wheel: bool,
19571964

crates/uv/src/commands/build.rs

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -227,14 +227,49 @@ async fn build_impl(
227227

228228
// Determine the build plan from the command-line arguments.
229229
let path = path.unwrap_or_else(|| &*CWD);
230-
let output_dir = path.join("dist");
230+
let metadata = match fs_err::tokio::metadata(path).await {
231+
Ok(metadata) => metadata,
232+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
233+
return Err(anyhow::anyhow!(
234+
"Source `{}` does not exist",
235+
path.simplified_display()
236+
));
237+
}
238+
Err(err) => return Err(err.into()),
239+
};
240+
241+
// Create the output directory.
242+
let output_dir = if metadata.is_dir() {
243+
path.join("dist")
244+
} else {
245+
path.parent().unwrap().to_path_buf()
246+
};
231247
fs_err::tokio::create_dir_all(&output_dir).await?;
232248

233-
let plan = match (sdist, wheel) {
234-
(false, false) => BuildPlan::SdistToWheel,
235-
(true, false) => BuildPlan::Sdist,
236-
(false, true) => BuildPlan::Wheel,
237-
(true, true) => BuildPlan::SdistAndWheel,
249+
// Determine the build plan.
250+
let plan = if metadata.is_dir() {
251+
// We're building from a directory.
252+
match (sdist, wheel) {
253+
(false, false) => BuildPlan::SdistToWheel,
254+
(false, true) => BuildPlan::Wheel,
255+
(true, false) => BuildPlan::Sdist,
256+
(true, true) => BuildPlan::SdistAndWheel,
257+
}
258+
} else {
259+
// We're building from a file, which must be a source distribution.
260+
match (sdist, wheel) {
261+
(false, true) => BuildPlan::WheelFromSdist,
262+
(false, false) => {
263+
return Err(anyhow::anyhow!(
264+
"Pass a `--wheel` explicitly to build a wheel from a source distribution"
265+
));
266+
}
267+
(true, false) | (true, true) => {
268+
return Err(anyhow::anyhow!(
269+
"Building an `--sdist` from a source distribution is not supported"
270+
));
271+
}
272+
}
238273
};
239274

240275
// Prepare some common arguments for the build.
@@ -253,7 +288,10 @@ async fn build_impl(
253288
// Extract the source distribution into a temporary directory.
254289
let path = output_dir.join(&sdist);
255290
let reader = fs_err::tokio::File::open(&path).await?;
256-
let ext = SourceDistExtension::from_path(&path)?;
291+
let ext = SourceDistExtension::from_path(&path).map_err(|err| {
292+
anyhow::anyhow!("`{}` is not a valid source distribution, as it ends with an unsupported extension. Expected one of: {err}.", path.simplified_display())
293+
294+
})?;
257295
let temp_dir = tempfile::tempdir_in(&output_dir)?;
258296
uv_extract::stream::archive(reader, ext, temp_dir.path()).await?;
259297

@@ -307,6 +345,36 @@ async fn build_impl(
307345

308346
BuiltDistributions::Both(sdist, wheel)
309347
}
348+
BuildPlan::WheelFromSdist => {
349+
// Extract the source distribution into a temporary directory.
350+
let reader = fs_err::tokio::File::open(&path).await?;
351+
let ext = SourceDistExtension::from_path(&path).map_err(|err| {
352+
anyhow::anyhow!("`{}` is not a valid built source. Expected to receive a source directory, or a source distribution ending in one of: {err}.", path.simplified_display())
353+
})?;
354+
let temp_dir = tempfile::tempdir_in(&output_dir)?;
355+
uv_extract::stream::archive(reader, ext, temp_dir.path()).await?;
356+
357+
// Extract the top-level directory from the archive.
358+
let extracted = match uv_extract::strip_component(temp_dir.path()) {
359+
Ok(top_level) => top_level,
360+
Err(uv_extract::Error::NonSingularArchive(_)) => temp_dir.path().to_path_buf(),
361+
Err(err) => return Err(err.into()),
362+
};
363+
364+
// Build a wheel from the source distribution.
365+
let builder = build_dispatch
366+
.setup_build(
367+
&extracted,
368+
subdirectory,
369+
&version_id,
370+
dist,
371+
BuildKind::Wheel,
372+
)
373+
.await?;
374+
let wheel = builder.build(&output_dir).await?;
375+
376+
BuiltDistributions::Wheel(wheel)
377+
}
310378
};
311379

312380
Ok(assets)
@@ -335,4 +403,7 @@ enum BuildPlan {
335403

336404
/// Build a source distribution and a wheel from source.
337405
SdistAndWheel,
406+
407+
/// Build a wheel from a source distribution.
408+
WheelFromSdist,
338409
}

crates/uv/tests/build.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use anyhow::Result;
55
use assert_fs::prelude::*;
66
use common::{uv_snapshot, TestContext};
7+
use predicates::prelude::predicate;
78

89
mod common;
910

@@ -40,6 +41,17 @@ fn build() -> Result<()> {
4041
Successfully built project-0.1.0.tar.gz and project-0.1.0-py3-none-any.whl
4142
"###);
4243

44+
project
45+
.child("dist")
46+
.child("project-0.1.0.tar.gz")
47+
.assert(predicate::path::is_file());
48+
project
49+
.child("dist")
50+
.child("project-0.1.0-py3-none-any.whl")
51+
.assert(predicate::path::is_file());
52+
53+
fs_err::remove_dir_all(project.child("dist"))?;
54+
4355
// Build the current working directory.
4456
uv_snapshot!(context.filters(), context.build().current_dir(project.path()), @r###"
4557
success: true
@@ -50,6 +62,17 @@ fn build() -> Result<()> {
5062
Successfully built project-0.1.0.tar.gz and project-0.1.0-py3-none-any.whl
5163
"###);
5264

65+
project
66+
.child("dist")
67+
.child("project-0.1.0.tar.gz")
68+
.assert(predicate::path::is_file());
69+
project
70+
.child("dist")
71+
.child("project-0.1.0-py3-none-any.whl")
72+
.assert(predicate::path::is_file());
73+
74+
fs_err::remove_dir_all(project.child("dist"))?;
75+
5376
// Error if there's nothing to build.
5477
uv_snapshot!(context.filters(), context.build(), @r###"
5578
success: false
@@ -96,6 +119,15 @@ fn sdist() -> Result<()> {
96119
Successfully built project-0.1.0.tar.gz
97120
"###);
98121

122+
project
123+
.child("dist")
124+
.child("project-0.1.0.tar.gz")
125+
.assert(predicate::path::is_file());
126+
project
127+
.child("dist")
128+
.child("project-0.1.0-py3-none-any.whl")
129+
.assert(predicate::path::missing());
130+
99131
Ok(())
100132
}
101133

@@ -132,6 +164,15 @@ fn wheel() -> Result<()> {
132164
Successfully built project-0.1.0-py3-none-any.whl
133165
"###);
134166

167+
project
168+
.child("dist")
169+
.child("project-0.1.0.tar.gz")
170+
.assert(predicate::path::missing());
171+
project
172+
.child("dist")
173+
.child("project-0.1.0-py3-none-any.whl")
174+
.assert(predicate::path::is_file());
175+
135176
Ok(())
136177
}
137178

@@ -168,5 +209,108 @@ fn sdist_wheel() -> Result<()> {
168209
Successfully built project-0.1.0.tar.gz and project-0.1.0-py3-none-any.whl
169210
"###);
170211

212+
project
213+
.child("dist")
214+
.child("project-0.1.0.tar.gz")
215+
.assert(predicate::path::is_file());
216+
project
217+
.child("dist")
218+
.child("project-0.1.0-py3-none-any.whl")
219+
.assert(predicate::path::is_file());
220+
221+
Ok(())
222+
}
223+
224+
#[test]
225+
fn wheel_from_sdist() -> Result<()> {
226+
let context = TestContext::new("3.12");
227+
228+
let project = context.temp_dir.child("project");
229+
230+
let pyproject_toml = project.child("pyproject.toml");
231+
pyproject_toml.write_str(
232+
r#"
233+
[project]
234+
name = "project"
235+
version = "0.1.0"
236+
requires-python = ">=3.12"
237+
dependencies = ["anyio==3.7.0"]
238+
239+
[build-system]
240+
requires = ["setuptools>=42"]
241+
build-backend = "setuptools.build_meta"
242+
"#,
243+
)?;
244+
245+
project.child("src").child("__init__.py").touch()?;
246+
247+
// Build the sdist.
248+
uv_snapshot!(context.filters(), context.build().arg("--sdist").current_dir(&project), @r###"
249+
success: true
250+
exit_code: 0
251+
----- stdout -----
252+
253+
----- stderr -----
254+
Successfully built project-0.1.0.tar.gz
255+
"###);
256+
257+
project
258+
.child("dist")
259+
.child("project-0.1.0.tar.gz")
260+
.assert(predicate::path::is_file());
261+
project
262+
.child("dist")
263+
.child("project-0.1.0-py3-none-any.whl")
264+
.assert(predicate::path::missing());
265+
266+
// Error if `--wheel` is not specified.
267+
uv_snapshot!(context.filters(), context.build().arg("./dist/project-0.1.0.tar.gz").current_dir(&project), @r###"
268+
success: false
269+
exit_code: 2
270+
----- stdout -----
271+
272+
----- stderr -----
273+
error: Pass a `--wheel` explicitly to build a wheel from a source distribution
274+
"###);
275+
276+
// Error if `--sdist` is not specified.
277+
uv_snapshot!(context.filters(), context.build().arg("./dist/project-0.1.0.tar.gz").arg("--sdist").current_dir(&project), @r###"
278+
success: false
279+
exit_code: 2
280+
----- stdout -----
281+
282+
----- stderr -----
283+
error: Building an `--sdist` from a source distribution is not supported
284+
"###);
285+
286+
// Build the wheel from the sdist.
287+
uv_snapshot!(context.filters(), context.build().arg("./dist/project-0.1.0.tar.gz").arg("--wheel").current_dir(&project), @r###"
288+
success: true
289+
exit_code: 0
290+
----- stdout -----
291+
292+
----- stderr -----
293+
Successfully built project-0.1.0-py3-none-any.whl
294+
"###);
295+
296+
project
297+
.child("dist")
298+
.child("project-0.1.0.tar.gz")
299+
.assert(predicate::path::is_file());
300+
project
301+
.child("dist")
302+
.child("project-0.1.0-py3-none-any.whl")
303+
.assert(predicate::path::is_file());
304+
305+
// Passing a wheel is an error.
306+
uv_snapshot!(context.filters(), context.build().arg("./dist/project-0.1.0-py3-none-any.whl").current_dir(&project), @r###"
307+
success: false
308+
exit_code: 2
309+
----- stdout -----
310+
311+
----- stderr -----
312+
error: `./dist/project-0.1.0-py3-none-any.whl` is not a valid built 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`.
313+
"###);
314+
171315
Ok(())
172316
}

docs/reference/cli.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5796,10 +5796,14 @@ uv venv [OPTIONS] [NAME]
57965796

57975797
Build Python packages into source distributions and wheels.
57985798

5799-
By default, `uv build` will build a source distribution ("sdist") from the source directory, and a binary distribution ("wheel") from the source distribution.
5799+
`uv build` accepts a path to a directory or source distribution, which defaults to the current working directory.
5800+
5801+
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.
58005802

58015803
`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.
58025804

5805+
If passed a source distribution, `uv build --wheel` will build a wheel from the source distribution.
5806+
58035807
<h3 class="cli-reference">Usage</h3>
58045808

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

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

5811-
<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>
5815+
<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>
58125816

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

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

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

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

60336037
</dd></dl>
60346038

0 commit comments

Comments
 (0)