Skip to content

Commit 5d8e990

Browse files
Support uv build --wheel from source distributions (#6898)
## Summary This PR allows users to run `uv build --wheel ./path/to/source.tar.gz` to build a wheel from a source distribution. This is also the default behavior if you run `uv build ./path/to/source.tar.gz`. If you pass `--sdist`, we error.
1 parent df84d25 commit 5d8e990

File tree

6 files changed

+299
-48
lines changed

6 files changed

+299
-48
lines changed

crates/uv-cli/src/lib.rs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -341,14 +341,20 @@ pub enum Commands {
341341
Venv(VenvArgs),
342342
/// Build Python packages into source distributions and wheels.
343343
///
344-
/// By default, `uv build` will build a source distribution ("sdist")
345-
/// from the source directory, and a binary distribution ("wheel") from
346-
/// the source distribution.
344+
/// `uv build` accepts a path to a directory or source distribution,
345+
/// which defaults to the current working directory.
346+
///
347+
/// By default, if passed a directory, `uv build` will build a source
348+
/// distribution ("sdist") from the source directory, and a binary
349+
/// distribution ("wheel") from the source distribution.
347350
///
348351
/// `uv build --sdist` can be used to build only the source distribution,
349352
/// `uv build --wheel` can be used to build only the binary distribution,
350353
/// and `uv build --sdist --wheel` can be used to build both distributions
351354
/// from source.
355+
///
356+
/// If passed a source distribution, `uv build --wheel` will build a wheel
357+
/// from the source distribution.
352358
#[command(
353359
after_help = "Use `uv help build` for more details.",
354360
after_long_help = ""
@@ -1942,15 +1948,17 @@ pub struct PipTreeArgs {
19421948
#[derive(Args)]
19431949
#[allow(clippy::struct_excessive_bools)]
19441950
pub struct BuildArgs {
1945-
/// The directory from which distributions should be built.
1951+
/// The directory from which distributions should be built, or a source
1952+
/// distribution archive to build into a wheel.
19461953
///
19471954
/// Defaults to the current working directory.
19481955
#[arg(value_parser = parse_file_path)]
1949-
pub src_dir: Option<PathBuf>,
1956+
pub src: Option<PathBuf>,
19501957

19511958
/// The output directory to which distributions should be written.
19521959
///
1953-
/// Defaults to the `dist` subdirectory within the source directory.
1960+
/// Defaults to the `dist` subdirectory within the source directory, or the
1961+
/// directory containing the source distribution archive.
19541962
#[arg(long, short, value_parser = parse_file_path)]
19551963
pub out_dir: Option<PathBuf>,
19561964

crates/uv/src/commands/build.rs

Lines changed: 130 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceError};
2727
/// Build source distributions and wheels.
2828
#[allow(clippy::fn_params_excessive_bools)]
2929
pub(crate) async fn build(
30-
src_dir: Option<PathBuf>,
30+
src: Option<PathBuf>,
3131
output_dir: Option<PathBuf>,
3232
sdist: bool,
3333
wheel: bool,
@@ -43,7 +43,7 @@ pub(crate) async fn build(
4343
printer: Printer,
4444
) -> Result<ExitStatus> {
4545
let assets = build_impl(
46-
src_dir.as_deref(),
46+
src.as_deref(),
4747
output_dir.as_deref(),
4848
sdist,
4949
wheel,
@@ -81,7 +81,7 @@ pub(crate) async fn build(
8181

8282
#[allow(clippy::fn_params_excessive_bools)]
8383
async fn build_impl(
84-
src_dir: Option<&Path>,
84+
src: Option<&Path>,
8585
output_dir: Option<&Path>,
8686
sdist: bool,
8787
wheel: bool,
@@ -118,41 +118,63 @@ async fn build_impl(
118118
.connectivity(connectivity)
119119
.native_tls(native_tls);
120120

121-
let src_dir = if let Some(src_dir) = src_dir {
122-
Cow::Owned(std::path::absolute(src_dir)?)
121+
let src = if let Some(src) = src {
122+
let src = std::path::absolute(src)?;
123+
let metadata = match fs_err::tokio::metadata(&src).await {
124+
Ok(metadata) => metadata,
125+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
126+
return Err(anyhow::anyhow!(
127+
"Source `{}` does not exist",
128+
src.user_display()
129+
));
130+
}
131+
Err(err) => return Err(err.into()),
132+
};
133+
if metadata.is_file() {
134+
Source::File(Cow::Owned(src))
135+
} else {
136+
Source::Directory(Cow::Owned(src))
137+
}
123138
} else {
124-
Cow::Borrowed(&*CWD)
139+
Source::Directory(Cow::Borrowed(&*CWD))
140+
};
141+
142+
let src_dir = match src {
143+
Source::Directory(ref src) => src,
144+
Source::File(ref src) => src.parent().unwrap(),
125145
};
126146

127147
let output_dir = if let Some(output_dir) = output_dir {
128-
std::path::absolute(output_dir)?
148+
Cow::Owned(std::path::absolute(output_dir)?)
129149
} else {
130-
src_dir.join("dist")
150+
match src {
151+
Source::Directory(ref src) => Cow::Owned(src.join("dist")),
152+
Source::File(ref src) => Cow::Borrowed(src.parent().unwrap()),
153+
}
131154
};
132155

133156
// (1) Explicit request from user
134157
let mut interpreter_request = python_request.map(PythonRequest::parse);
135158

136159
// (2) Request from `.python-version`
137160
if interpreter_request.is_none() {
138-
interpreter_request = PythonVersionFile::discover(src_dir.as_ref(), no_config, false)
161+
interpreter_request = PythonVersionFile::discover(&src_dir, no_config, false)
139162
.await?
140163
.and_then(PythonVersionFile::into_version);
141164
}
142165

143166
// (3) `Requires-Python` in `pyproject.toml`
144167
if interpreter_request.is_none() {
145-
let project =
146-
match VirtualProject::discover(src_dir.as_ref(), &DiscoveryOptions::default()).await {
147-
Ok(project) => Some(project),
148-
Err(WorkspaceError::MissingProject(_)) => None,
149-
Err(WorkspaceError::MissingPyprojectToml) => None,
150-
Err(WorkspaceError::NonWorkspace(_)) => None,
151-
Err(err) => {
152-
warn_user_once!("{err}");
153-
None
154-
}
155-
};
168+
let project = match VirtualProject::discover(src_dir, &DiscoveryOptions::default()).await {
169+
Ok(project) => Some(project),
170+
Err(WorkspaceError::MissingProject(_)) => None,
171+
Err(WorkspaceError::MissingPyprojectToml) => None,
172+
Err(WorkspaceError::NonWorkspace(_)) => None,
173+
Err(err) => {
174+
warn_user_once!("{err}");
175+
None
176+
}
177+
};
156178

157179
if let Some(project) = project {
158180
interpreter_request = find_requires_python(project.workspace())?
@@ -242,27 +264,49 @@ async fn build_impl(
242264
concurrency,
243265
);
244266

267+
// Create the output directory.
245268
fs_err::tokio::create_dir_all(&output_dir).await?;
246269

247-
// Determine the build plan from the command-line arguments.
248-
let plan = match (sdist, wheel) {
249-
(false, false) => BuildPlan::SdistToWheel,
250-
(true, false) => BuildPlan::Sdist,
251-
(false, true) => BuildPlan::Wheel,
252-
(true, true) => BuildPlan::SdistAndWheel,
270+
// Determine the build plan.
271+
let plan = match &src {
272+
Source::File(_) => {
273+
// We're building from a file, which must be a source distribution.
274+
match (sdist, wheel) {
275+
(false, true) => BuildPlan::WheelFromSdist,
276+
(false, false) => {
277+
return Err(anyhow::anyhow!(
278+
"Pass `--wheel` explicitly to build a wheel from a source distribution"
279+
));
280+
}
281+
(true, _) => {
282+
return Err(anyhow::anyhow!(
283+
"Building an `--sdist` from a source distribution is not supported"
284+
));
285+
}
286+
}
287+
}
288+
Source::Directory(_) => {
289+
// We're building from a directory.
290+
match (sdist, wheel) {
291+
(false, false) => BuildPlan::SdistToWheel,
292+
(false, true) => BuildPlan::Wheel,
293+
(true, false) => BuildPlan::Sdist,
294+
(true, true) => BuildPlan::SdistAndWheel,
295+
}
296+
}
253297
};
254298

255299
// Prepare some common arguments for the build.
256300
let subdirectory = None;
257-
let version_id = src_dir.file_name().unwrap().to_string_lossy();
301+
let version_id = src.path().file_name().unwrap().to_string_lossy();
258302
let dist = None;
259303

260304
let assets = match plan {
261305
BuildPlan::SdistToWheel => {
262306
// Build the sdist.
263307
let builder = build_dispatch
264308
.setup_build(
265-
src_dir.as_ref(),
309+
src.path(),
266310
subdirectory,
267311
&version_id,
268312
dist,
@@ -274,7 +318,9 @@ async fn build_impl(
274318
// Extract the source distribution into a temporary directory.
275319
let path = output_dir.join(&sdist);
276320
let reader = fs_err::tokio::File::open(&path).await?;
277-
let ext = SourceDistExtension::from_path(&path)?;
321+
let ext = SourceDistExtension::from_path(path.as_path()).map_err(|err| {
322+
anyhow::anyhow!("`{}` is not a valid source distribution, as it ends with an unsupported extension. Expected one of: {err}.", path.user_display())
323+
})?;
278324
let temp_dir = tempfile::tempdir_in(&output_dir)?;
279325
uv_extract::stream::archive(reader, ext, temp_dir.path()).await?;
280326

@@ -302,7 +348,7 @@ async fn build_impl(
302348
BuildPlan::Sdist => {
303349
let builder = build_dispatch
304350
.setup_build(
305-
src_dir.as_ref(),
351+
src.path(),
306352
subdirectory,
307353
&version_id,
308354
dist,
@@ -316,7 +362,7 @@ async fn build_impl(
316362
BuildPlan::Wheel => {
317363
let builder = build_dispatch
318364
.setup_build(
319-
src_dir.as_ref(),
365+
src.path(),
320366
subdirectory,
321367
&version_id,
322368
dist,
@@ -330,7 +376,7 @@ async fn build_impl(
330376
BuildPlan::SdistAndWheel => {
331377
let builder = build_dispatch
332378
.setup_build(
333-
src_dir.as_ref(),
379+
src.path(),
334380
subdirectory,
335381
&version_id,
336382
dist,
@@ -341,7 +387,7 @@ async fn build_impl(
341387

342388
let builder = build_dispatch
343389
.setup_build(
344-
src_dir.as_ref(),
390+
src.path(),
345391
subdirectory,
346392
&version_id,
347393
dist,
@@ -352,12 +398,59 @@ async fn build_impl(
352398

353399
BuiltDistributions::Both(output_dir.join(&sdist), output_dir.join(&wheel))
354400
}
401+
BuildPlan::WheelFromSdist => {
402+
// Extract the source distribution into a temporary directory.
403+
let reader = fs_err::tokio::File::open(src.path()).await?;
404+
let ext = SourceDistExtension::from_path(src.path()).map_err(|err| {
405+
anyhow::anyhow!("`{}` is not a valid build source. Expected to receive a source directory, or a source distribution ending in one of: {err}.", src.path().user_display())
406+
})?;
407+
let temp_dir = tempfile::tempdir_in(&output_dir)?;
408+
uv_extract::stream::archive(reader, ext, temp_dir.path()).await?;
409+
410+
// Extract the top-level directory from the archive.
411+
let extracted = match uv_extract::strip_component(temp_dir.path()) {
412+
Ok(top_level) => top_level,
413+
Err(uv_extract::Error::NonSingularArchive(_)) => temp_dir.path().to_path_buf(),
414+
Err(err) => return Err(err.into()),
415+
};
416+
417+
// Build a wheel from the source distribution.
418+
let builder = build_dispatch
419+
.setup_build(
420+
&extracted,
421+
subdirectory,
422+
&version_id,
423+
dist,
424+
BuildKind::Wheel,
425+
)
426+
.await?;
427+
let wheel = builder.build(&output_dir).await?;
428+
429+
BuiltDistributions::Wheel(output_dir.join(wheel))
430+
}
355431
};
356432

357433
Ok(assets)
358434
}
359435

360-
#[derive(Debug, Clone, PartialEq)]
436+
#[derive(Debug, Clone, PartialEq, Eq)]
437+
enum Source<'a> {
438+
/// The input source is a file (i.e., a source distribution in a `.tar.gz` or `.zip` file).
439+
File(Cow<'a, Path>),
440+
/// The input source is a directory.
441+
Directory(Cow<'a, Path>),
442+
}
443+
444+
impl<'a> Source<'a> {
445+
fn path(&self) -> &Path {
446+
match self {
447+
Source::File(path) => path.as_ref(),
448+
Source::Directory(path) => path.as_ref(),
449+
}
450+
}
451+
}
452+
453+
#[derive(Debug, Clone, PartialEq, Eq)]
361454
enum BuiltDistributions {
362455
/// A built wheel.
363456
Wheel(PathBuf),
@@ -380,4 +473,7 @@ enum BuildPlan {
380473

381474
/// Build a source distribution and a wheel from source.
382475
SdistAndWheel,
476+
477+
/// Build a wheel from a source distribution.
478+
WheelFromSdist,
383479
}

crates/uv/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -671,7 +671,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
671671
);
672672

673673
commands::build(
674-
args.src_dir,
674+
args.src,
675675
args.out_dir,
676676
args.sdist,
677677
args.wheel,

crates/uv/src/settings.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1615,7 +1615,7 @@ impl PipCheckSettings {
16151615
#[allow(clippy::struct_excessive_bools)]
16161616
#[derive(Debug, Clone)]
16171617
pub(crate) struct BuildSettings {
1618-
pub(crate) src_dir: Option<PathBuf>,
1618+
pub(crate) src: Option<PathBuf>,
16191619
pub(crate) out_dir: Option<PathBuf>,
16201620
pub(crate) sdist: bool,
16211621
pub(crate) wheel: bool,
@@ -1628,7 +1628,7 @@ impl BuildSettings {
16281628
/// Resolve the [`BuildSettings`] from the CLI and filesystem configuration.
16291629
pub(crate) fn resolve(args: BuildArgs, filesystem: Option<FilesystemOptions>) -> Self {
16301630
let BuildArgs {
1631-
src_dir,
1631+
src,
16321632
out_dir,
16331633
sdist,
16341634
wheel,
@@ -1639,7 +1639,7 @@ impl BuildSettings {
16391639
} = args;
16401640

16411641
Self {
1642-
src_dir,
1642+
src,
16431643
out_dir,
16441644
sdist,
16451645
wheel,

0 commit comments

Comments
 (0)