diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 9f8c64878adb..ee756194df89 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -341,14 +341,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 = "" @@ -1942,15 +1948,17 @@ pub struct PipTreeArgs { #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct BuildArgs { - /// The directory from which distributions should be built. + /// The directory from which distributions 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)] - pub src_dir: Option, + pub src: Option, /// The output directory to which distributions should be written. /// - /// Defaults to the `dist` subdirectory within the source directory. + /// Defaults to the `dist` subdirectory within the source directory, or the + /// directory containing the source distribution archive. #[arg(long, short, value_parser = parse_file_path)] pub out_dir: Option, diff --git a/crates/uv/src/commands/build.rs b/crates/uv/src/commands/build.rs index ed657c91a95f..f3e32896f6f7 100644 --- a/crates/uv/src/commands/build.rs +++ b/crates/uv/src/commands/build.rs @@ -27,7 +27,7 @@ use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceError}; /// Build source distributions and wheels. #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn build( - src_dir: Option, + src: Option, output_dir: Option, sdist: bool, wheel: bool, @@ -43,7 +43,7 @@ pub(crate) async fn build( printer: Printer, ) -> Result { let assets = build_impl( - src_dir.as_deref(), + src.as_deref(), output_dir.as_deref(), sdist, wheel, @@ -81,7 +81,7 @@ pub(crate) async fn build( #[allow(clippy::fn_params_excessive_bools)] async fn build_impl( - src_dir: Option<&Path>, + src: Option<&Path>, output_dir: Option<&Path>, sdist: bool, wheel: bool, @@ -118,16 +118,39 @@ async fn build_impl( .connectivity(connectivity) .native_tls(native_tls); - let src_dir = if let Some(src_dir) = src_dir { - Cow::Owned(std::path::absolute(src_dir)?) + let src = if let Some(src) = src { + let src = std::path::absolute(src)?; + let metadata = match fs_err::tokio::metadata(&src).await { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(anyhow::anyhow!( + "Source `{}` does not exist", + src.user_display() + )); + } + Err(err) => return Err(err.into()), + }; + if metadata.is_file() { + Source::File(Cow::Owned(src)) + } else { + Source::Directory(Cow::Owned(src)) + } } else { - Cow::Borrowed(&*CWD) + Source::Directory(Cow::Borrowed(&*CWD)) + }; + + let src_dir = match src { + Source::Directory(ref src) => src, + Source::File(ref src) => src.parent().unwrap(), }; let output_dir = if let Some(output_dir) = output_dir { - std::path::absolute(output_dir)? + Cow::Owned(std::path::absolute(output_dir)?) } else { - src_dir.join("dist") + match src { + Source::Directory(ref src) => Cow::Owned(src.join("dist")), + Source::File(ref src) => Cow::Borrowed(src.parent().unwrap()), + } }; // (1) Explicit request from user @@ -135,24 +158,23 @@ async fn build_impl( // (2) Request from `.python-version` if interpreter_request.is_none() { - interpreter_request = PythonVersionFile::discover(src_dir.as_ref(), no_config, false) + interpreter_request = PythonVersionFile::discover(&src_dir, 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.as_ref(), &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 - } - }; + 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())? @@ -242,19 +264,41 @@ async fn build_impl( concurrency, ); + // Create the output directory. fs_err::tokio::create_dir_all(&output_dir).await?; - // Determine the build plan from the command-line arguments. - 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 = match &src { + Source::File(_) => { + // 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" + )); + } + } + } + Source::Directory(_) => { + // 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, + } + } }; // Prepare some common arguments for the build. let subdirectory = None; - let version_id = src_dir.file_name().unwrap().to_string_lossy(); + let version_id = src.path().file_name().unwrap().to_string_lossy(); let dist = None; let assets = match plan { @@ -262,7 +306,7 @@ async fn build_impl( // Build the sdist. let builder = build_dispatch .setup_build( - src_dir.as_ref(), + src.path(), subdirectory, &version_id, dist, @@ -274,7 +318,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?; @@ -302,7 +348,7 @@ async fn build_impl( BuildPlan::Sdist => { let builder = build_dispatch .setup_build( - src_dir.as_ref(), + src.path(), subdirectory, &version_id, dist, @@ -316,7 +362,7 @@ async fn build_impl( BuildPlan::Wheel => { let builder = build_dispatch .setup_build( - src_dir.as_ref(), + src.path(), subdirectory, &version_id, dist, @@ -330,7 +376,7 @@ async fn build_impl( BuildPlan::SdistAndWheel => { let builder = build_dispatch .setup_build( - src_dir.as_ref(), + src.path(), subdirectory, &version_id, dist, @@ -341,7 +387,7 @@ async fn build_impl( let builder = build_dispatch .setup_build( - src_dir.as_ref(), + src.path(), subdirectory, &version_id, dist, @@ -352,12 +398,59 @@ async fn build_impl( BuiltDistributions::Both(output_dir.join(&sdist), output_dir.join(&wheel)) } + BuildPlan::WheelFromSdist => { + // Extract the source distribution into a temporary directory. + let reader = fs_err::tokio::File::open(src.path()).await?; + let ext = SourceDistExtension::from_path(src.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}.", src.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(output_dir.join(wheel)) + } }; Ok(assets) } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] +enum Source<'a> { + /// The input source is a file (i.e., a source distribution in a `.tar.gz` or `.zip` file). + File(Cow<'a, Path>), + /// The input source is a directory. + Directory(Cow<'a, Path>), +} + +impl<'a> Source<'a> { + fn path(&self) -> &Path { + match self { + Source::File(path) => path.as_ref(), + Source::Directory(path) => path.as_ref(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] enum BuiltDistributions { /// A built wheel. Wheel(PathBuf), @@ -380,4 +473,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/src/lib.rs b/crates/uv/src/lib.rs index b4ba7f655eee..079e3d2e67d3 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -671,7 +671,7 @@ async fn run(cli: Cli) -> Result { ); commands::build( - args.src_dir, + args.src, args.out_dir, args.sdist, args.wheel, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index f80c730d31b2..a601cc2e7fac 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1615,7 +1615,7 @@ impl PipCheckSettings { #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub(crate) struct BuildSettings { - pub(crate) src_dir: Option, + pub(crate) src: Option, pub(crate) out_dir: Option, pub(crate) sdist: bool, pub(crate) wheel: bool, @@ -1628,7 +1628,7 @@ impl BuildSettings { /// Resolve the [`BuildSettings`] from the CLI and filesystem configuration. pub(crate) fn resolve(args: BuildArgs, filesystem: Option) -> Self { let BuildArgs { - src_dir, + src, out_dir, sdist, wheel, @@ -1639,7 +1639,7 @@ impl BuildSettings { } = args; Self { - src_dir, + src, out_dir, sdist, wheel, diff --git a/crates/uv/tests/build.rs b/crates/uv/tests/build.rs index a9667ab812bf..38d27312f492 100644 --- a/crates/uv/tests/build.rs +++ b/crates/uv/tests/build.rs @@ -40,6 +40,17 @@ fn build() -> Result<()> { Successfully built project/dist/project-0.1.0.tar.gz and project/dist/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 @@ -50,6 +61,17 @@ fn build() -> Result<()> { Successfully built dist/project-0.1.0.tar.gz and dist/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 @@ -115,6 +137,15 @@ fn sdist() -> Result<()> { Successfully built dist/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(()) } @@ -151,6 +182,15 @@ fn wheel() -> Result<()> { Successfully built dist/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(()) } @@ -187,5 +227,108 @@ fn sdist_wheel() -> Result<()> { Successfully built dist/project-0.1.0.tar.gz and dist/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 dist/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 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 dist/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 cb4e8644a7ef..a49edd40bbb0 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -6186,19 +6186,23 @@ uv venv [OPTIONS] [PATH] 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

``` -uv build [OPTIONS] [SRC_DIR] +uv build [OPTIONS] [SRC] ```

Arguments

-
SRC_DIR

The directory from which distributions should be built.

+
SRC

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

Defaults to the current working directory.

@@ -6364,7 +6368,7 @@ uv build [OPTIONS] [SRC_DIR]
--out-dir, -o out-dir

The output directory to which distributions should be written.

-

Defaults to the dist subdirectory within the source directory.

+

Defaults to the dist subdirectory within the source directory, or the directory containing the source distribution archive.

--prerelease prerelease

The strategy to use when considering pre-release versions.