Skip to content
Merged
1 change: 1 addition & 0 deletions src/binding_generator/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ where
writer,
&context.project.project_layout,
context.project.pyproject_toml.as_ref(),
&context.project.target_dir,
)
.context("Failed to add the python module to the package")?;
}
Expand Down
41 changes: 41 additions & 0 deletions src/module_writer/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::fmt::Write as _;
use std::io::Read as _;
use std::path::Path;
use std::path::PathBuf;

Expand Down Expand Up @@ -114,11 +115,50 @@ impl<T: ModuleWriterInternal> ModuleWriter for T {
}
}

/// Returns a filter for [WalkBuilder::filter_entry] that skips the cargo target directory and
/// cache directories tagged with `CACHEDIR.TAG`, unless they contain a python package (#3319)
///
/// See https://bford.info/cachedir/
pub(crate) fn skip_build_dirs(
target_dir: &Path,
python_packages: &[PathBuf],
) -> impl Fn(&ignore::DirEntry) -> bool + Send + Sync + 'static {
let target_dir = target_dir.normalize().ok().map(|dir| dir.into_path_buf());
let python_packages = python_packages.to_vec();
move |entry| {
let is_build_dir = target_dir.as_deref() == Some(entry.path())
|| (entry
.file_type()
.is_some_and(|file_type| file_type.is_dir())
&& has_cachedir_tag(entry.path()));
!is_build_dir
|| python_packages
.iter()
.any(|package| package.starts_with(entry.path()))
}
}

/// Signature that a `CACHEDIR.TAG` file starts with
const CACHEDIR_TAG_SIGNATURE: &[u8] = b"Signature: 8a477f597d28d172789f06886806bc55";

/// Whether the directory has a `CACHEDIR.TAG` file starting with the signature
fn has_cachedir_tag(dir: &Path) -> bool {
let tag = dir.join("CACHEDIR.TAG");
if !fs::symlink_metadata(&tag).is_ok_and(|metadata| metadata.is_file()) {
return false;
}
let mut signature = [0; CACHEDIR_TAG_SIGNATURE.len()];
fs::File::open(&tag)
.and_then(|mut file| file.read_exact(&mut signature))
.is_ok_and(|()| signature == CACHEDIR_TAG_SIGNATURE)
}

/// Adds the python part of a mixed project to the writer,
pub fn write_python_part(
writer: &mut VirtualWriter<WheelWriter>,
project_layout: &ProjectLayout,
pyproject_toml: Option<&PyProjectToml>,
target_dir: &Path,
) -> Result<()> {
let python_dir = &project_layout.python_dir;
let mut python_packages = Vec::new();
Expand All @@ -138,6 +178,7 @@ pub fn write_python_part(
.parents(false)
.git_global(false)
.git_exclude(false)
.filter_entry(skip_build_dirs(target_dir, &python_packages))
.build()
{
let absolute = match absolute {
Expand Down
9 changes: 7 additions & 2 deletions src/source_distribution/pyproject.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use crate::module_writer::skip_build_dirs;
use crate::pyproject_toml::Format;
use crate::{ModuleWriter, PyProjectToml, SDistWriter, VirtualWriter};
use anyhow::{Context, Result, bail};
use ignore::WalkBuilder;
use normpath::PathExt as _;
use path_slash::PathExt as _;
use pyproject_toml::check_pep639_glob;
Expand Down Expand Up @@ -198,8 +200,11 @@ pub(super) fn add_python_sources(
python_packages.push(package_path);
}

for package in python_packages {
for entry in ignore::Walk::new(package) {
for package in &python_packages {
for entry in WalkBuilder::new(package)
.filter_entry(skip_build_dirs(&project.target_dir, &python_packages))
.build()
{
let source = entry?.into_path();
if is_compiled_artifact(&source) {
debug!("Ignoring {}", source.display());
Expand Down
128 changes: 124 additions & 4 deletions tests/common/other.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@ pub fn build_source_distribution(
package: impl AsRef<Path>,
sdist_generator: SdistGenerator,
unique_name: &str,
) -> Result<Archive<GzDecoder<File>>> {
let target_dir = crate::common::case_target_dir(unique_name);
build_source_distribution_with_target_dir(package, sdist_generator, target_dir, unique_name)
}

fn build_source_distribution_with_target_dir(
package: impl AsRef<Path>,
sdist_generator: SdistGenerator,
target_dir: PathBuf,
unique_name: &str,
) -> Result<Archive<GzDecoder<File>>> {
let manifest_path = package.as_ref().join("Cargo.toml");
let sdist_directory = crate::common::case_wheel_dir(unique_name);
Expand All @@ -172,7 +182,7 @@ pub fn build_source_distribution(
cargo: CargoOptions {
manifest_path: Some(manifest_path),
quiet: true,
target_dir: Some(crate::common::case_target_dir(unique_name)),
target_dir: Some(target_dir),
..Default::default()
},
..Default::default()
Expand Down Expand Up @@ -274,6 +284,16 @@ pub fn check_sdist_mtimes(
}

fn build_wheel_files(package: impl AsRef<Path>, unique_name: &str) -> Result<ZipArchive<File>> {
let target_dir = crate::common::shared_target_dir(&package);
let _fixture_lock = crate::common::lock_fixture(&package)?;
build_wheel_files_with_target_dir(package, target_dir, unique_name)
}

fn build_wheel_files_with_target_dir(
package: impl AsRef<Path>,
target_dir: PathBuf,
unique_name: &str,
) -> Result<ZipArchive<File>> {
let manifest_path = package.as_ref().join("Cargo.toml");
let wheel_directory = Path::new("test-crates").join("wheels").join(unique_name);

Expand All @@ -285,7 +305,7 @@ fn build_wheel_files(package: impl AsRef<Path>, unique_name: &str) -> Result<Zip
cargo: CargoOptions {
manifest_path: Some(manifest_path),
quiet: true,
target_dir: Some(crate::common::shared_target_dir(&package)),
target_dir: Some(target_dir),
..Default::default()
},
platform: PlatformOptions {
Expand All @@ -300,11 +320,9 @@ fn build_wheel_files(package: impl AsRef<Path>, unique_name: &str) -> Result<Zip
.strip(Some(false))
.editable(false)
.build()?;
let fixture_lock = crate::common::lock_fixture(&package)?;
Comment thread
messense marked this conversation as resolved.
let wheels = BuildOrchestrator::new(&build_context)
.build_wheels()
.context("Failed to build wheels")?;
drop(fixture_lock);
assert!(!wheels.is_empty());
let wheel_path = &wheels[0].path;

Expand Down Expand Up @@ -737,6 +755,108 @@ pub fn test_unreadable_dir() -> Result<()> {
Ok(())
}

/// Test that target and cache directories in the python package aren't packaged outside of git.
///
/// See https://github.com/PyO3/maturin/issues/3319
pub fn test_target_dir_in_python_package() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let project_dir = temp_dir.path().join("bin-with-python-module");
copy_dir_recursive(
Path::new("test-crates/bin-with-python-module"),
&project_dir,
)?;

// Like the default `target/wheels` output, creating it first keeps cargo from tagging it
let target_dir = project_dir.join("bin_with_python_module/target");
fs_err::create_dir_all(&target_dir)?;
let cache_dir = project_dir.join("bin_with_python_module/cache");
fs_err::create_dir_all(&cache_dir)?;
fs_err::write(
cache_dir.join("CACHEDIR.TAG"),
"Signature: 8a477f597d28d172789f06886806bc55\n",
)?;
// With the wrong signature, it's not a cache directory
let data_dir = project_dir.join("bin_with_python_module/data");
fs_err::create_dir_all(&data_dir)?;
fs_err::write(
data_dir.join("CACHEDIR.TAG"),
"Signature: ffffffffffffffffffffffffffffffff\n",
)?;
// The tag must be a regular file, not a symlink
#[cfg(unix)]
{
let symlink_dir = project_dir.join("bin_with_python_module/symlink");
fs_err::create_dir_all(&symlink_dir)?;
fs_err::os::unix::fs::symlink(
cache_dir.join("CACHEDIR.TAG"),
symlink_dir.join("CACHEDIR.TAG"),
)?;
}

let wheel = build_wheel_files_with_target_dir(
&project_dir,
target_dir,
"wheel-files-bin-with-python-module-target-dir",
)?;
let package_files: Vec<_> = wheel
.file_names()
.filter(|name| name.starts_with("bin_with_python_module/"))
.collect();
#[allow(unused_mut)]
let mut expected = vec![
"bin_with_python_module/__init__.py",
"bin_with_python_module/data/CACHEDIR.TAG",
];
#[cfg(unix)]
expected.push("bin_with_python_module/symlink/CACHEDIR.TAG");
assert_eq!(package_files, expected);
Ok(())
}

/// Test that target and cache directories in the python package aren't packaged in sdists. The
/// python package of pyo3-mixed-src is outside the crate, so `cargo package` doesn't list them.
///
/// See https://github.com/PyO3/maturin/issues/3319
pub fn test_target_dir_in_python_package_sdist() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let project_dir = temp_dir.path().join("pyo3-mixed-src");
copy_dir_recursive(Path::new("test-crates/pyo3-mixed-src"), &project_dir)?;

// Leftovers of an earlier build in a target directory that cargo didn't tag
let target_dir = project_dir.join("src/pyo3_mixed_src/target");
fs_err::create_dir_all(target_dir.join("debug"))?;
fs_err::write(target_dir.join("debug/libpyo3_mixed_src.rlib"), "")?;
let cache_dir = project_dir.join("src/pyo3_mixed_src/cache");
fs_err::create_dir_all(&cache_dir)?;
fs_err::write(
cache_dir.join("CACHEDIR.TAG"),
"Signature: 8a477f597d28d172789f06886806bc55\n",
)?;

let mut sdist = build_source_distribution_with_target_dir(
project_dir.join("rust"),
SdistGenerator::Cargo,
target_dir,
"sdist-pyo3-mixed-src-target-dir",
)?;
let mut package_files = Vec::new();
for entry in sdist.entries()? {
let path = format!("{}", entry?.path()?.display());
if path.starts_with("pyo3_mixed_src-2.1.3/src/pyo3_mixed_src/") {
package_files.push(path);
}
}
assert_eq!(
package_files,
[
"pyo3_mixed_src-2.1.3/src/pyo3_mixed_src/__init__.py",
"pyo3_mixed_src-2.1.3/src/pyo3_mixed_src/python_module/__init__.py",
"pyo3_mixed_src-2.1.3/src/pyo3_mixed_src/python_module/double.py",
]
);
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.
Expand Down
5 changes: 5 additions & 0 deletions tests/run/sdist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,11 @@ fn pyo3_mixed_src_layout_sdist() {
)
}

#[test]
fn target_dir_in_python_package_sdist() {
handle_result(other::test_target_dir_in_python_package_sdist())
}

#[test]
fn pyo3_mixed_include_exclude_sdist() {
run_sdist_case(
Expand Down
5 changes: 5 additions & 0 deletions tests/run/wheel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,8 @@ fn pyo3_wheel_record_has_normalized_paths() {
"wheel-record-pyo3-mixed-include-exclude",
))
}

#[test]
fn target_dir_in_python_package() {
handle_result(other::test_target_dir_in_python_package())
}
Loading