Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/build_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>-<version>.
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 {
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
67 changes: 56 additions & 11 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -75,7 +75,10 @@ enum Command {
require_equals = false
)]
strip: Option<bool>,
/// 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)]
Expand Down Expand Up @@ -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());
}
Expand All @@ -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))
Expand All @@ -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::<Vec<_>>();
publish.non_interactive_on_ci();

upload_ui(&items, &publish)?
}
Command::ListPython { target } => {
Expand Down Expand Up @@ -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<bool>) -> Result<PathBuf> {
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();
Expand Down
5 changes: 4 additions & 1 deletion tests/cmd/build.stdout
Original file line number Diff line number Diff line change
Expand Up @@ -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 [<compatibility>...]
Control the platform tag and PyPI compatibility.
Expand Down
60 changes: 59 additions & 1 deletion tests/common/other.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Path>, 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(())
}
8 changes: 8 additions & 0 deletions tests/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"
Expand Down
Loading