Skip to content

Commit a0dca02

Browse files
committed
Respect metadata_directory in build_wheel per PEP 517
When a PEP 517 frontend passes metadata_directory to build_wheel, the built wheel should use the pre-generated metadata from that directory. This enables wrapping build backends that modify metadata in prepare_metadata_for_build_wheel. Python side: when metadata_directory is not None, set MATURIN_PEP517_METADATA_DIR in the subprocess environment. Rust side: write_dist_info() checks the env var and copies files from the pre-existing .dist-info directory instead of regenerating them. The WHEEL file is always regenerated to ensure correct tags, and RECORD is skipped since it is generated by WheelWriter::finish(). If the env var is set but the expected directory does not exist, an error is raised instead of silently falling back. Fixes #1973
1 parent e2ee46d commit a0dca02

File tree

3 files changed

+230
-2
lines changed

3 files changed

+230
-2
lines changed

maturin/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,15 @@ def _build_wheel(
108108

109109
command = [*base_command, *options]
110110

111+
env = _get_env()
112+
if metadata_directory is not None:
113+
if env is None:
114+
env = os.environ.copy()
115+
env["MATURIN_PEP517_METADATA_DIR"] = metadata_directory
116+
111117
print("Running `{}`".format(" ".join(command)))
112118
sys.stdout.flush()
113-
result = subprocess.run(command, stdout=subprocess.PIPE, env=_get_env())
119+
result = subprocess.run(command, stdout=subprocess.PIPE, env=env)
114120
sys.stdout.buffer.write(result.stdout)
115121
sys.stdout.flush()
116122
if result.returncode != 0:

src/module_writer/mock_writer.rs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ fn write_dist_info_uses_license_file_sources() -> Result<()> {
149149
}
150150

151151
#[test]
152+
#[serial_test::serial]
152153
fn write_dist_info_rejects_absolute_license_paths() {
153154
use pep440_rs::Version;
154155
use std::str::FromStr;
@@ -173,3 +174,136 @@ fn write_dist_info_rejects_absolute_license_paths() {
173174
"unexpected error: {err:#}"
174175
);
175176
}
177+
178+
#[test]
179+
#[serial_test::serial]
180+
fn write_dist_info_respects_metadata_directory_env_var() -> Result<()> {
181+
use pep440_rs::Version;
182+
use std::str::FromStr;
183+
184+
let temp_dir = tempfile::tempdir()?;
185+
let pyproject_dir = temp_dir.path().join("crate");
186+
fs_err::create_dir_all(&pyproject_dir)?;
187+
188+
let metadata = Metadata24::new("test-pkg".to_string(), Version::from_str("1.0.0").unwrap());
189+
let dist_info_name = "test_pkg-1.0.0.dist-info";
190+
191+
// Create a pre-generated .dist-info directory with custom METADATA
192+
let metadata_dir = temp_dir.path().join("metadata");
193+
let pre_existing_dir = metadata_dir.join(dist_info_name);
194+
fs_err::create_dir_all(&pre_existing_dir)?;
195+
196+
let custom_metadata =
197+
"Metadata-Version: 2.4\nName: test-pkg\nVersion: 1.0.0\nClassifier: Custom :: Classifier\n";
198+
fs_err::write(pre_existing_dir.join("METADATA"), custom_metadata)?;
199+
fs_err::write(pre_existing_dir.join("WHEEL"), "should be overwritten")?;
200+
fs_err::write(pre_existing_dir.join("RECORD"), "should be skipped")?;
201+
fs_err::write(
202+
pre_existing_dir.join("entry_points.txt"),
203+
"[console_scripts]\nfoo=bar:main\n",
204+
)?;
205+
206+
// Create a licenses/ subdirectory
207+
let licenses_dir = pre_existing_dir.join("licenses");
208+
fs_err::create_dir_all(&licenses_dir)?;
209+
fs_err::write(licenses_dir.join("LICENSE"), "MIT License")?;
210+
211+
// Per PEP 517, metadata_directory points to the .dist-info directory itself
212+
// SAFETY: This test is serialized and the env var is removed before returning.
213+
unsafe { std::env::set_var("MATURIN_PEP517_METADATA_DIR", &pre_existing_dir) };
214+
let mut writer = VirtualWriter::new(MockWriter::default(), Override::empty());
215+
let tags = &["cp310-cp310-manylinux_2_17_x86_64".to_string()];
216+
let result = write_dist_info(&mut writer, &pyproject_dir, &metadata, tags);
217+
unsafe { std::env::remove_var("MATURIN_PEP517_METADATA_DIR") };
218+
result?;
219+
220+
let files = writer.finish()?;
221+
222+
// METADATA should be the custom one, not regenerated
223+
let metadata_key = Path::new(dist_info_name).join("METADATA");
224+
assert_eq!(
225+
str::from_utf8(&files[&metadata_key]).unwrap(),
226+
custom_metadata
227+
);
228+
229+
// WHEEL should be regenerated with correct tags, not the pre-existing content
230+
let wheel_key = Path::new(dist_info_name).join("WHEEL");
231+
let wheel_content = str::from_utf8(&files[&wheel_key]).unwrap();
232+
assert!(
233+
wheel_content.contains("Tag: cp310-cp310-manylinux_2_17_x86_64"),
234+
"WHEEL should contain the correct tag, got: {wheel_content}"
235+
);
236+
assert!(
237+
!wheel_content.contains("should be overwritten"),
238+
"WHEEL should be regenerated, not copied"
239+
);
240+
241+
// RECORD should not be present (it's generated later by WheelWriter)
242+
let record_key = Path::new(dist_info_name).join("RECORD");
243+
assert!(!files.contains_key(&record_key));
244+
245+
// entry_points.txt should be copied
246+
let ep_key = Path::new(dist_info_name).join("entry_points.txt");
247+
assert_eq!(
248+
str::from_utf8(&files[&ep_key]).unwrap(),
249+
"[console_scripts]\nfoo=bar:main\n"
250+
);
251+
252+
// License file from subdirectory should be copied
253+
let license_key = Path::new(dist_info_name).join("licenses").join("LICENSE");
254+
assert_eq!(str::from_utf8(&files[&license_key]).unwrap(), "MIT License");
255+
256+
Ok(())
257+
}
258+
259+
#[test]
260+
#[serial_test::serial]
261+
fn write_dist_info_metadata_dir_as_parent_directory() -> Result<()> {
262+
use pep440_rs::Version;
263+
use std::str::FromStr;
264+
265+
let temp_dir = tempfile::tempdir()?;
266+
let pyproject_dir = temp_dir.path().join("crate");
267+
fs_err::create_dir_all(&pyproject_dir)?;
268+
269+
let metadata = Metadata24::new("test-pkg".to_string(), Version::from_str("1.0.0").unwrap());
270+
let dist_info_name = "test_pkg-1.0.0.dist-info";
271+
272+
// Create a parent directory containing the .dist-info subdirectory
273+
let parent_dir = temp_dir.path().join("metadata");
274+
let pre_existing_dir = parent_dir.join(dist_info_name);
275+
fs_err::create_dir_all(&pre_existing_dir)?;
276+
277+
let custom_metadata =
278+
"Metadata-Version: 2.4\nName: test-pkg\nVersion: 1.0.0\nClassifier: Custom :: Classifier\n";
279+
fs_err::write(pre_existing_dir.join("METADATA"), custom_metadata)?;
280+
fs_err::write(pre_existing_dir.join("WHEEL"), "should be overwritten")?;
281+
282+
// Set env var to the parent directory (PEP 517 spec form)
283+
// SAFETY: This test is serialized and the env var is removed before returning.
284+
unsafe { std::env::set_var("MATURIN_PEP517_METADATA_DIR", &parent_dir) };
285+
let mut writer = VirtualWriter::new(MockWriter::default(), Override::empty());
286+
let tags = &["cp310-cp310-manylinux_2_17_x86_64".to_string()];
287+
let result = write_dist_info(&mut writer, &pyproject_dir, &metadata, tags);
288+
unsafe { std::env::remove_var("MATURIN_PEP517_METADATA_DIR") };
289+
result?;
290+
291+
let files = writer.finish()?;
292+
293+
// METADATA should be the custom one
294+
let metadata_key = Path::new(dist_info_name).join("METADATA");
295+
assert_eq!(
296+
str::from_utf8(&files[&metadata_key]).unwrap(),
297+
custom_metadata
298+
);
299+
300+
// WHEEL should be regenerated
301+
let wheel_key = Path::new(dist_info_name).join("WHEEL");
302+
let wheel_content = str::from_utf8(&files[&wheel_key]).unwrap();
303+
assert!(
304+
wheel_content.contains("Tag: cp310-cp310-manylinux_2_17_x86_64"),
305+
"WHEEL should contain the correct tag, got: {wheel_content}"
306+
);
307+
308+
Ok(())
309+
}

src/module_writer/mod.rs

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,12 @@ fn add_data_subdir(
289289
Ok(())
290290
}
291291

292-
/// Creates the .dist-info directory and fills it with all metadata files except RECORD
292+
/// Creates the .dist-info directory and fills it with all metadata files except RECORD.
293+
///
294+
/// If the `MATURIN_PEP517_METADATA_DIR` environment variable is set, copies the
295+
/// pre-generated metadata files from that directory instead of regenerating them.
296+
/// The WHEEL file is always regenerated to ensure correct tags. This implements
297+
/// the PEP 517 requirement that `build_wheel` respects `metadata_directory`.
293298
pub fn write_dist_info(
294299
writer: &mut VirtualWriter<impl ModuleWriterInternal>,
295300
pyproject_dir: &Path,
@@ -298,6 +303,38 @@ pub fn write_dist_info(
298303
) -> Result<PathBuf> {
299304
let dist_info_dir = metadata24.get_dist_info_dir();
300305

306+
if let Ok(metadata_dir) = std::env::var("MATURIN_PEP517_METADATA_DIR") {
307+
let metadata_path = Path::new(&metadata_dir);
308+
// Support both forms:
309+
// 1. Direct .dist-info path (pip's behavior)
310+
// 2. Parent directory containing the .dist-info subdirectory (per PEP 517 spec)
311+
let pre_existing = if metadata_path.is_dir()
312+
&& metadata_path
313+
.file_name()
314+
.is_some_and(|n| n.to_string_lossy().ends_with(".dist-info"))
315+
{
316+
metadata_path.to_path_buf()
317+
} else {
318+
let nested = metadata_path.join(&dist_info_dir);
319+
if nested.is_dir() {
320+
nested
321+
} else {
322+
bail!(
323+
"MATURIN_PEP517_METADATA_DIR is set to '{}' but no .dist-info directory \
324+
was found (tried both '{}' directly and '{}')",
325+
metadata_dir,
326+
metadata_path.display(),
327+
metadata_path.join(&dist_info_dir).display(),
328+
);
329+
}
330+
};
331+
debug!(
332+
"Using pre-generated metadata from {}",
333+
pre_existing.display()
334+
);
335+
return write_dist_info_from_dir(writer, &dist_info_dir, &pre_existing, tags);
336+
}
337+
301338
writer.add_bytes(
302339
dist_info_dir.join("METADATA"),
303340
None,
@@ -362,6 +399,57 @@ pub fn write_dist_info(
362399
Ok(dist_info_dir)
363400
}
364401

402+
/// Copies pre-generated metadata files from a `.dist-info` directory on disk into the wheel,
403+
/// but always regenerates the WHEEL file to ensure correct tags.
404+
fn write_dist_info_from_dir(
405+
writer: &mut VirtualWriter<impl ModuleWriterInternal>,
406+
dist_info_dir: &Path,
407+
source_dir: &Path,
408+
tags: &[String],
409+
) -> Result<PathBuf> {
410+
// Always regenerate WHEEL to ensure correct tags for the built wheel
411+
writer.add_bytes(
412+
dist_info_dir.join("WHEEL"),
413+
None,
414+
wheel_file(tags)?.as_bytes(),
415+
false,
416+
)?;
417+
418+
// Copy all other files from the pre-generated .dist-info directory
419+
for entry in fs::read_dir(source_dir)? {
420+
let entry = entry?;
421+
let file_name = entry.file_name();
422+
let file_name_str = file_name.to_string_lossy();
423+
424+
// Skip WHEEL (already regenerated) and RECORD (will be regenerated by WheelWriter)
425+
if file_name_str == "WHEEL" || file_name_str == "RECORD" {
426+
continue;
427+
}
428+
429+
let entry_path = entry.path();
430+
if entry_path.is_dir() {
431+
// Recursively add subdirectories (e.g. licenses/)
432+
for sub_entry in walkdir::WalkDir::new(&entry_path) {
433+
let sub_entry = sub_entry?;
434+
if sub_entry.file_type().is_file() {
435+
let rel = sub_entry.path().strip_prefix(source_dir).with_context(|| {
436+
format!(
437+
"walkdir entry '{}' is not under source dir '{}'",
438+
sub_entry.path().display(),
439+
source_dir.display()
440+
)
441+
})?;
442+
writer.add_file(dist_info_dir.join(rel), sub_entry.path(), false)?;
443+
}
444+
}
445+
} else {
446+
writer.add_file(dist_info_dir.join(&file_name), &entry_path, false)?;
447+
}
448+
}
449+
450+
Ok(dist_info_dir.to_owned())
451+
}
452+
365453
/// Add a pth file to wheel root for editable installs
366454
pub fn write_pth(
367455
writer: &mut VirtualWriter<WheelWriter>,

0 commit comments

Comments
 (0)