diff --git a/src/build_context.rs b/src/build_context.rs index d53272c39..bd9836c7b 100644 --- a/src/build_context.rs +++ b/src/build_context.rs @@ -40,6 +40,60 @@ use std::path::{Path, PathBuf}; use tracing::instrument; use zip::DateTime; +/// Unpacks an sdist tarball into a temporary directory and returns the path +/// to the Cargo.toml inside it, along with the tempdir handle (which must +/// be kept alive for the duration of the build). +/// +/// The Cargo.toml path is resolved by checking `[tool.maturin.manifest-path]` +/// in the sdist's `pyproject.toml`, falling back to `Cargo.toml` at the +/// sdist root directory. +pub fn unpack_sdist(sdist_path: &Path) -> Result<(tempfile::TempDir, PathBuf)> { + let tmp = tempfile::tempdir().context("Failed to create temporary directory")?; + let gz = flate2::read::GzDecoder::new( + fs::File::open(sdist_path) + .with_context(|| format!("Failed to open sdist {}", sdist_path.display()))?, + ); + let mut archive = tar::Archive::new(gz); + archive + .unpack(tmp.path()) + .context("Failed to unpack source distribution")?; + + // The sdist contains a single top-level directory named -. + let entries: Vec<_> = fs::read_dir(tmp.path()) + .context("Failed to read unpacked sdist directory")? + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .collect(); + let top_dir = match entries.len() { + 1 => entries[0].path(), + n => bail!( + "Expected exactly one top-level directory in sdist, found {}", + n + ), + }; + + // Resolve the Cargo.toml path: check pyproject.toml for [tool.maturin.manifest-path], + // otherwise default to Cargo.toml at the sdist root. + let pyproject_file = top_dir.join("pyproject.toml"); + let cargo_toml = if pyproject_file.is_file() { + let pyproject = PyProjectToml::new(&pyproject_file)?; + if let Some(manifest_path) = pyproject.manifest_path() { + top_dir.join(manifest_path) + } else { + top_dir.join("Cargo.toml") + } + } else { + top_dir.join("Cargo.toml") + }; + if !cargo_toml.exists() { + bail!( + "Cargo.toml not found in unpacked sdist at {}", + cargo_toml.display() + ); + } + Ok((tmp, cargo_toml)) +} + /// Contains all the metadata required to build the crate #[derive(Clone)] pub struct BuildContext { diff --git a/src/lib.rs b/src/lib.rs index 0a5405196..790b5082e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,7 @@ #![deny(missing_docs)] pub use crate::bridge::{Abi3Version, BridgeModel, PyO3, PyO3Crate}; -pub use crate::build_context::{BuildContext, BuiltWheelMetadata}; +pub use crate::build_context::{BuildContext, BuiltWheelMetadata, unpack_sdist}; pub use crate::build_options::{BuildOptions, CargoOptions, TargetTriple}; pub use crate::cargo_toml::CargoToml; pub use crate::compile::{BuildArtifact, CompileResult, compile}; diff --git a/src/main.rs b/src/main.rs index 8229a2ade..61ce59652 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ use clap::{Parser, Subcommand}; use ignore::overrides::Override; use maturin::{ BridgeModel, BuildOptions, CargoOptions, DevelopOptions, PathWriter, PythonInterpreter, Target, - TargetTriple, VirtualWriter, develop, find_path_deps, write_dist_info, + TargetTriple, VirtualWriter, develop, find_path_deps, unpack_sdist, write_dist_info, }; #[cfg(feature = "schemars")] use maturin::{GenerateJsonSchemaOptions, generate_json_schema}; @@ -75,7 +75,10 @@ enum Command { require_equals = false )] strip: Option, - /// Build a source distribution + /// Build a source distribution and build wheels from it. + /// + /// This verifies that the source distribution is complete and can be + /// used to build the project from source. #[arg(long)] sdist: bool, #[command(flatten)] @@ -401,16 +404,27 @@ fn run() -> Result<()> { if release { build.profile = Some("release".to_string()); } + // Keep tempdir alive for the duration of the build + let _sdist_tmp; + if sdist { + // Build sdist first, then build wheels from the unpacked sdist + // to verify that the source distribution is complete. + let sdist_path = build_sdist(&build, strip)?; + let (tmp, cargo_toml) = unpack_sdist(&sdist_path)?; + _sdist_tmp = Some(tmp); + eprintln!( + "📦 Building wheels from source distribution at {}", + cargo_toml.parent().unwrap().display() + ); + build.cargo.manifest_path = Some(cargo_toml); + } else { + _sdist_tmp = None; + } let build_context = build .into_build_context() .strip(strip) .editable(false) .build()?; - if sdist { - build_context - .build_source_distribution()? - .context("Failed to build source distribution, pyproject.toml not found")?; - } let wheels = build_context.build_wheels()?; assert!(!wheels.is_empty()); } @@ -429,6 +443,24 @@ fn run() -> Result<()> { build.profile = Some("dev".to_string()); } + // Keep tempdir alive for the duration of the build + let _sdist_tmp; + let mut sdist_path = None; + if !no_sdist { + // Build sdist first, then build wheels from the unpacked sdist + let path = build_sdist(&build, Some(!no_strip))?; + let (tmp, cargo_toml) = unpack_sdist(&path)?; + _sdist_tmp = Some(tmp); + eprintln!( + "📦 Building wheels from source distribution at {}", + cargo_toml.parent().unwrap().display() + ); + build.cargo.manifest_path = Some(cargo_toml); + sdist_path = Some(path); + } else { + _sdist_tmp = None; + } + let mut build_context = build .into_build_context() .strip(Some(!no_strip)) @@ -442,19 +474,17 @@ fn run() -> Result<()> { .cargo_options .profile .get_or_insert_with(|| "release".to_string()); - if profile == "dev" { eprintln!("⚠️ Warning: You're publishing debug wheels"); } let mut wheels = build_context.build_wheels()?; - if !no_sdist && let Some(sd) = build_context.build_source_distribution()? { - wheels.push(sd); + if let Some(sdist_path) = sdist_path { + wheels.push((sdist_path, "source".to_string())); } let items = wheels.into_iter().map(|wheel| wheel.0).collect::>(); publish.non_interactive_on_ci(); - upload_ui(&items, &publish)? } Command::ListPython { target } => { @@ -548,6 +578,21 @@ fn run() -> Result<()> { Ok(()) } +/// Build a source distribution from the given build options, returning the sdist path. +fn build_sdist(build: &BuildOptions, strip: Option) -> Result { + let sdist_context = build + .clone() + .into_build_context() + .strip(strip) + .editable(false) + .sdist_only(true) + .build()?; + let (sdist_path, _) = sdist_context + .build_source_distribution()? + .context("Failed to build source distribution, pyproject.toml not found")?; + Ok(sdist_path) +} + #[cfg(not(debug_assertions))] fn setup_panic_hook() { let default_hook = std::panic::take_hook(); diff --git a/tests/cmd/build.stdout b/tests/cmd/build.stdout index 1a906ad28..2caf1a6fd 100644 --- a/tests/cmd/build.stdout +++ b/tests/cmd/build.stdout @@ -15,7 +15,10 @@ Options: [possible values: true, false] --sdist - Build a source distribution + Build a source distribution and build wheels from it. + + This verifies that the source distribution is complete and can be used to build the + project from source. --compatibility [...] Control the platform tag and PyPI compatibility. diff --git a/tests/common/other.rs b/tests/common/other.rs index a05c010cb..a31c8c679 100644 --- a/tests/common/other.rs +++ b/tests/common/other.rs @@ -4,7 +4,7 @@ use expect_test::Expect; use flate2::read::GzDecoder; use fs_err::File; use maturin::pyproject_toml::{SdistGenerator, ToolMaturin}; -use maturin::{BuildOptions, CargoOptions, PlatformTag}; +use maturin::{BuildOptions, CargoOptions, PlatformTag, unpack_sdist}; use pretty_assertions::assert_eq; use std::collections::BTreeSet; use std::io::Read; @@ -529,3 +529,61 @@ pub fn test_unreadable_dir() -> Result<()> { wheel_result?; Ok(()) } + +/// Test that building wheels from an sdist works correctly. +/// This simulates the `maturin build --sdist` workflow: build sdist first, +/// unpack it, then build wheels from the unpacked sdist. +pub fn test_build_wheels_from_sdist(package: impl AsRef, unique_name: &str) -> Result<()> { + let package = package.as_ref(); + let temp_dir = tempfile::tempdir()?; + let sdist_dir = temp_dir.path().join("sdist"); + let wheel_dir = temp_dir.path().join("wheels"); + + // Step 1: Build the sdist + let sdist_options = BuildOptions { + out: Some(sdist_dir), + cargo: CargoOptions { + manifest_path: Some(package.join("Cargo.toml")), + quiet: true, + target_dir: Some(PathBuf::from(format!("test-crates/targets/{unique_name}"))), + ..Default::default() + }, + ..Default::default() + }; + let sdist_context = sdist_options + .into_build_context() + .strip(Some(false)) + .editable(false) + .sdist_only(true) + .build()?; + let (sdist_path, _) = sdist_context + .build_source_distribution()? + .context("Failed to build source distribution")?; + + // Step 2: Unpack sdist and build wheels from it + let (_tmp, cargo_toml) = unpack_sdist(&sdist_path)?; + let wheel_options = BuildOptions { + out: Some(wheel_dir), + cargo: CargoOptions { + manifest_path: Some(cargo_toml), + quiet: true, + target_dir: Some(PathBuf::from(format!( + "test-crates/targets/{unique_name}_from_sdist" + ))), + ..Default::default() + }, + ..Default::default() + }; + let wheel_context = wheel_options + .into_build_context() + .strip(Some(cfg!(feature = "faster-tests"))) + .editable(false) + .build()?; + let wheels = wheel_context.build_wheels()?; + assert!( + !wheels.is_empty(), + "Expected at least one wheel to be built" + ); + + Ok(()) +} diff --git a/tests/run.rs b/tests/run.rs index 492c6e1d7..efa3034c7 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -733,6 +733,14 @@ fn workspace_cargo_lock() { handle_result(other::test_workspace_cargo_lock()) } +#[test] +fn build_wheels_from_sdist_hello_world() { + handle_result(other::test_build_wheels_from_sdist( + "test-crates/hello-world", + "build_wheels_from_sdist_hello_world", + )) +} + #[test] fn workspace_members_beneath_pyproject_sdist() { let cargo_toml = expect![[r#"