Repository: PyO3/maturin (language: python)

Issue:
`maturin build --sdist`: README.md was already added for workspace crate
### Bug Description

With a README file both in the workspace root and the crate root:

 - https://github.com/mitmproxy/mitmproxy_rs/blob/main/README.md
 - https://github.com/mitmproxy/mitmproxy_rs/blob/main/mitmproxy-rs/README.md

building a source distribution fails:

```
$ cd mitmproxy-rs
$ RUST_LOG=maturin=debug maturin build --sdist
2024-10-02T14:12:48.035925Z DEBUG into_build_context: maturin::project_layout: Found pyproject.toml in working directory at "/Users/mhils/git/mitmproxy_rs/mitmproxy-rs/pyproject.toml"
2024-10-02T14:12:48.037519Z DEBUG into_build_context: maturin::project_layout: Using cargo manifest path from working directory: "/Users/mhils/git/mitmproxy_rs/mitmproxy-rs/Cargo.toml"
2024-10-02T14:12:48.037891Z DEBUG into_build_context:resolve_cargo_metadata: maturin::project_layout: Resolving cargo metadata from "/Users/mhils/git/mitmproxy_rs/mitmproxy-rs/Cargo.toml"
2024-10-02T14:12:48.247759Z  INFO into_build_context:resolve_cargo_metadata: maturin::project_layout: close time.busy=210ms time.idle=1.25µs
2024-10-02T14:12:48.248315Z DEBUG into_build_context: maturin::project_layout: Project layout resolved project_root=/Users/mhils/git/mitmproxy_rs/mitmproxy-rs python_dir=/Users/mhils/git/mitmproxy_rs/mitmproxy-rs rust_module=/Users/mhils/git/mitmproxy_rs/mitmproxy-rs/mitmproxy_rs python_module=/Users/mhils/git/mitmproxy_rs/mitmproxy-rs/mitmproxy_rs extension_name=mitmproxy_rs module_name=mitmproxy_rs
🍹 Building a mixed python/rust project
🔗 Found pyo3 bindings with abi3 support for Python ≥ 3.10
2024-10-02T14:12:48.340243Z DEBUG into_build_context:check_executable: maturin::python_interpreter: Found CPython interpreter at /Users/mhils/git/mitmproxy/venv/bin/python executable=python
2024-10-02T14:12:48.340263Z  INFO into_build_context:check_executable: maturin::python_interpreter: close time.busy=49.0ms time.idle=1.83µs executable=python
🐍 Not using a specific python interpreter
📡 Using build options features from pyproject.toml
2024-10-02T14:12:48.340320Z  INFO into_build_context: maturin::build_options: close time.busy=305ms time.idle=1.50µs
2024-10-02T14:12:48.388449Z DEBUG maturin::source_distribution: Found path dependencies: ["mitmproxy"]
2024-10-02T14:12:48.388466Z DEBUG maturin::source_distribution: Found sdist root: /Users/mhils/git/mitmproxy_rs
2024-10-02T14:12:48.388469Z DEBUG maturin::source_distribution: Adding path dependency: mitmproxy at /Users/mhils/git/mitmproxy_rs/Cargo.toml
[...]
2024-10-02T14:12:48.503569Z DEBUG maturin::module_writer: Adding mitmproxy_rs-0.10.1.dev0/LICENSE from /Users/mhils/git/mitmproxy_rs/LICENSE
2024-10-02T14:12:48.503663Z DEBUG maturin::module_writer: Adding mitmproxy_rs-0.10.1.dev0/README.md from /Users/mhils/git/mitmproxy_rs/README.md
[...]
2024-10-02T14:12:48.664433Z DEBUG maturin::source_distribution: Rewriting Cargo.toml `package.readme` at /Users/mhils/git/mitmproxy_rs/mitmproxy-rs/Cargo.toml
2024-10-02T14:12:48.664452Z DEBUG maturin::source_distribution: Rewriting Cargo.toml `workspace.members` at /Users/mhils/git/mitmproxy_rs/mitmproxy-rs/Cargo.toml
[...]
2024-10-02T14:12:48.664641Z DEBUG maturin::module_writer: Adding mitmproxy_rs-0.10.1.dev0/mitmproxy-rs/README.md from /Users/mhils/git/mitmproxy_rs/mitmproxy-rs/README.md
[...]
2024-10-02T14:12:48.667225Z DEBUG maturin::module_writer: Adding mitmproxy_rs-0.10.1.dev0/mitmproxy-rs/stubtest-allowlist.txt from /Users/mhils/git/mitmproxy_rs/mitmproxy-rs/stubtest-allowlist.txt
💥 maturin failed
  Caused by: Failed to build source distribution
  Caused by: File mitmproxy_rs-0.10.1.dev0/README.md was already added from /Users/mhils/git/mitmproxy_rs/README.md, can't added it from /Users/mhils/git/mitmproxy_rs/mitmproxy-rs/README.md
```

[Cargo.toml](https://github.com/mitmproxy/mitmproxy_rs/blob/main/Cargo.toml), [mitmproxy-rs/Cargo.toml](https://github.com/mitmproxy/mitmproxy_rs/blob/main/mitmproxy-rs/Cargo.toml), and [mitmproxy-rs/pyproject.toml](https://github.com/mitmproxy/mitmproxy_rs/blob/main/mitmproxy-rs/pyproject.toml) do not specify any (custom) settings w.r.t. READMEs. I would have expected maturin to use `mitmproxy-rs/README.md` and not `README.md`.

This is a regression that started in 1.7.1. Refs https://github.com/PyO3/maturin/pull/2184 and https://github.com/PyO3/maturin/issues/2154.

### Your maturin version (`maturin --version`)

1.7.4

### Your Python version (`python -V`)

3.12

### Your pip version (`pip -V`)

all

### What bindings you're using

pyo3

### Does `cargo build` work?

- [X] Yes, it works


Discussion / hints:
What is ETA for this? (no pressure intended)

Fix patch:
diff --git a/src/source_distribution.rs b/src/source_distribution.rs
index 2b255ea13..bbfd71b45 100644
--- a/src/source_distribution.rs
+++ b/src/source_distribution.rs
@@ -15,7 +15,7 @@ use std::path::{Path, PathBuf};
 use std::process::Command;
 use std::str;
 use toml_edit::DocumentMut;
-use tracing::debug;
+use tracing::{debug, trace};
 
 /// Path dependency information.
 /// It may be in a different workspace than the root crate.
@@ -185,6 +185,11 @@ fn add_crate_to_source_distribution(
     root_crate: bool,
     skip_cargo_toml: bool,
 ) -> Result<()> {
+    debug!(
+        "Getting cargo package file list for {}",
+        manifest_path.as_ref().display()
+    );
+    let prefix = prefix.as_ref();
     let manifest_path = manifest_path.as_ref();
     let args = ["package", "--list", "--allow-dirty", "--manifest-path"];
     let output = Command::new("cargo")
@@ -207,41 +212,53 @@ fn add_crate_to_source_distribution(
         );
     }
     if !output.stderr.is_empty() {
-        eprintln!("From `cargo {}`:", args.join(" "));
+        eprintln!(
+            "From `cargo {} {}`:",
+            args.join(" "),
+            manifest_path.display()
+        );
         std::io::stderr().write_all(&output.stderr)?;
     }
 
-    let file_list: Vec<&Path> = str::from_utf8(&output.stdout)
+    let file_list: Vec<&str> = str::from_utf8(&output.stdout)
         .context("Cargo printed invalid utf-8 ಠ_ಠ")?
         .lines()
-        .map(Path::new)
         .collect();
 
+    trace!("File list: {}", file_list.join(", "));
+
     // manifest_dir should be a relative path
     let manifest_dir = manifest_path.parent().unwrap();
-    let target_source: Vec<(PathBuf, PathBuf)> = file_list
-        .iter()
+    let target_source: Vec<_> = file_list
+        .into_iter()
         .map(|relative_to_manifests| {
             let relative_to_cwd = manifest_dir.join(relative_to_manifests);
-            (relative_to_manifests.to_path_buf(), relative_to_cwd)
+            (relative_to_manifests, relative_to_cwd)
         })
-        // We rewrite Cargo.toml and add it separately
         .filter(|(target, source)| {
             #[allow(clippy::if_same_then_else)]
-            // Skip generated files. See https://github.com/rust-lang/cargo/issues/7938#issuecomment-593280660
-            // and https://github.com/PyO3/maturin/issues/449
-            if target == Path::new("Cargo.toml.orig") || target == Path::new("Cargo.toml") {
+            if *target == "Cargo.toml.orig" {
+                // Skip generated files. See https://github.com/rust-lang/cargo/issues/7938#issuecomment-593280660
+                // and https://github.com/PyO3/maturin/issues/449
+                false
+            } else if *target == "Cargo.toml" {
+                // We rewrite Cargo.toml and add it separately
                 false
-            } else if root_crate && target == Path::new("pyproject.toml") {
-                // pyproject.toml is handled separately because it has be to put in the root dir
+            } else if root_crate && *target == "pyproject.toml" {
+                // pyproject.toml is handled separately because it has to be put in the root dir
                 // of source distribution
                 false
-            } else if matches!(target.extension(), Some(ext) if ext == "pyc" || ext == "pyd" || ext == "so") {
+            } else if prefix.components().count() == 1 && *target == "pyproject.toml" {
+                // Skip pyproject.toml for cases when the root is in a workspace member and both the
+                // member and the root have a pyproject.toml.
+                debug!("Skipping potentially non-main {}", prefix.join(target).display());
+                false
+            } else if matches!(Path::new(target).extension(), Some(ext) if ext == "pyc" || ext == "pyd" || ext == "so") {
                 // Technically, `cargo package --list` should handle this,
                 // but somehow it doesn't on Alpine Linux running in GitHub Actions,
                 // so we do it manually here.
                 // See https://github.com/PyO3/maturin/pull/1255#issuecomment-1308838786
-                debug!("Ignoring {}", target.display());
+                debug!("Ignoring {}", target);
                 false
             } else {
                 source.exists()
@@ -249,7 +266,6 @@ fn add_crate_to_source_distribution(
         })
         .collect();
 
-    let prefix = prefix.as_ref();
     writer.add_directory(prefix)?;
 
     let cargo_toml_path = prefix.join(manifest_path.file_name().unwrap());
@@ -468,6 +484,7 @@ fn add_cargo_package_files_to_sdist(
         .with_context(|| format!("Failed to add path dependency {}", name))?;
     }
 
+    debug!("Adding the main crate {}", manifest_path.display());
     // Add the main crate
     let abs_manifest_path = manifest_path
         .normalize()
@@ -503,7 +520,12 @@ fn add_cargo_package_files_to_sdist(
             .into_path_buf();
         // Add readme next to Cargo.toml so we don't get collisions between crates using readmes
         // higher up the file tree.
-        writer.add_file(root_dir.join(readme.file_name().unwrap()), &abs_readme)?;
+        writer.add_file(
+            root_dir
+                .join(relative_main_crate_manifest_dir)
+                .join(readme.file_name().unwrap()),
+            &abs_readme,
+        )?;
     }
 
     // Add Cargo.lock file and workspace Cargo.toml


Test patch:
diff --git a/test-crates/workspace-inverted-order/Cargo.lock b/test-crates/workspace-inverted-order/Cargo.lock
new file mode 100644
index 000000000..44c9c8b48
--- /dev/null
+++ b/test-crates/workspace-inverted-order/Cargo.lock
@@ -0,0 +1,176 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "autocfg"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "indoc"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5"
+
+[[package]]
+name = "libc"
+version = "0.2.161"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
+
+[[package]]
+name = "path_dep_with_root"
+version = "0.1.0"
+dependencies = [
+ "pyo3",
+ "top_level",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.88"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "pyo3"
+version = "0.22.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d922163ba1f79c04bc49073ba7b32fd5a8d3b76a87c955921234b8e77333c51"
+dependencies = [
+ "cfg-if",
+ "indoc",
+ "libc",
+ "memoffset",
+ "once_cell",
+ "portable-atomic",
+ "pyo3-build-config",
+ "pyo3-ffi",
+ "pyo3-macros",
+ "unindent",
+]
+
+[[package]]
+name = "pyo3-build-config"
+version = "0.22.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc38c5feeb496c8321091edf3d63e9a6829eab4b863b4a6a65f26f3e9cc6b179"
+dependencies = [
+ "once_cell",
+ "target-lexicon",
+]
+
+[[package]]
+name = "pyo3-ffi"
+version = "0.22.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94845622d88ae274d2729fcefc850e63d7a3ddff5e3ce11bd88486db9f1d357d"
+dependencies = [
+ "libc",
+ "pyo3-build-config",
+]
+
+[[package]]
+name = "pyo3-macros"
+version = "0.22.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e655aad15e09b94ffdb3ce3d217acf652e26bbc37697ef012f5e5e348c716e5e"
+dependencies = [
+ "proc-macro2",
+ "pyo3-macros-backend",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pyo3-macros-backend"
+version = "0.22.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae1e3f09eecd94618f60a455a23def79f79eba4dc561a97324bf9ac8c6df30ce"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "pyo3-build-config",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
+[[package]]
+name = "top_level"
+version = "0.1.0"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
+
+[[package]]
+name = "unindent"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce"
diff --git a/test-crates/workspace-inverted-order/Cargo.toml b/test-crates/workspace-inverted-order/Cargo.toml
new file mode 100644
index 000000000..cd05d5706
--- /dev/null
+++ b/test-crates/workspace-inverted-order/Cargo.toml
@@ -0,0 +1,17 @@
+[workspace]
+members = [
+    ".",
+    "path-dep-with-root",
+]
+
+[workspace.package]
+authors = []
+version = "0.10.2-dev"
+publish = false
+repository = "https://github.com/mitmproxy/mitmproxy-rs"
+
+[package]
+name = "top_level"
+version = "0.1.0"
+description = "root dep"
+edition = "2021"
diff --git a/test-crates/workspace-inverted-order/README.md b/test-crates/workspace-inverted-order/README.md
new file mode 100644
index 000000000..68f7f5a03
--- /dev/null
+++ b/test-crates/workspace-inverted-order/README.md
@@ -0,0 +1,3 @@
+# Root readme
+
+In this workspace, the build root is in a subdirectory, while the workspace root also contains a crate.
diff --git a/test-crates/workspace-inverted-order/check_installed/check_installed.py b/test-crates/workspace-inverted-order/check_installed/check_installed.py
new file mode 100755
index 000000000..1c4a2f129
--- /dev/null
+++ b/test-crates/workspace-inverted-order/check_installed/check_installed.py
@@ -0,0 +1,6 @@
+#!/usr/bin/env python3
+import path_dep_with_root
+
+assert path_dep_with_root.add_number(2) == 44
+
+print("SUCCESS")
diff --git a/test-crates/workspace-inverted-order/path-dep-with-root/Cargo.toml b/test-crates/workspace-inverted-order/path-dep-with-root/Cargo.toml
new file mode 100644
index 000000000..08939762f
--- /dev/null
+++ b/test-crates/workspace-inverted-order/path-dep-with-root/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "path_dep_with_root"
+version = "0.1.0"
+description = "path dep"
+edition = "2021"
+
+[lib]
+name = "path_dep_with_root"
+crate-type = ["lib", "cdylib"]
+
+[dependencies]
+top_level = { path = "../" }
+pyo3 = { version = "0.22.5", features = ["abi3", "abi3-py310", "extension-module"] }
diff --git a/test-crates/workspace-inverted-order/path-dep-with-root/README.md b/test-crates/workspace-inverted-order/path-dep-with-root/README.md
new file mode 100644
index 000000000..0dd4a9be1
--- /dev/null
+++ b/test-crates/workspace-inverted-order/path-dep-with-root/README.md
@@ -0,0 +1,1 @@
+# Path readme
diff --git a/test-crates/workspace-inverted-order/path-dep-with-root/pyproject.toml b/test-crates/workspace-inverted-order/path-dep-with-root/pyproject.toml
new file mode 100644
index 000000000..997f80bf9
--- /dev/null
+++ b/test-crates/workspace-inverted-order/path-dep-with-root/pyproject.toml
@@ -0,0 +1,8 @@
+[build-system]
+requires = ["maturin>=1,<2"]
+build-backend = "maturin"
+
+[project]
+name = "mitmproxy_path"
+version = "0.1.0"
+requires-python = ">=3.10"
diff --git a/test-crates/workspace-inverted-order/path-dep-with-root/src/lib.rs b/test-crates/workspace-inverted-order/path-dep-with-root/src/lib.rs
new file mode 100644
index 000000000..31342a46b
--- /dev/null
+++ b/test-crates/workspace-inverted-order/path-dep-with-root/src/lib.rs
@@ -0,0 +1,12 @@
+use pyo3::prelude::*;
+
+#[pymodule]
+mod path_dep_with_root {
+    use pyo3::pyfunction;
+    use top_level::NUMBER;
+
+    #[pyfunction]
+    fn add_number(x: u32) -> u32 {
+        x + NUMBER
+    }
+}
diff --git a/test-crates/workspace-inverted-order/pyproject.toml b/test-crates/workspace-inverted-order/pyproject.toml
new file mode 100644
index 000000000..0fa885941
--- /dev/null
+++ b/test-crates/workspace-inverted-order/pyproject.toml
@@ -0,0 +1,2 @@
+[tool.dummy]
+value = 1
diff --git a/test-crates/workspace-inverted-order/src/lib.rs b/test-crates/workspace-inverted-order/src/lib.rs
new file mode 100755
index 000000000..2fefc8c7b
--- /dev/null
+++ b/test-crates/workspace-inverted-order/src/lib.rs
@@ -0,0 +1,1 @@
+pub const NUMBER: u32 = 42;
diff --git a/tests/run.rs b/tests/run.rs
index adc5fa996..6b70ac93b 100644
--- a/tests/run.rs
+++ b/tests/run.rs
@@ -481,6 +481,17 @@ fn integration_readme_duplication() {
     ));
 }
 
+#[test]
+fn integration_workspace_inverted_order() {
+    handle_result(integration::test_integration(
+        "test-crates/workspace-inverted-order/path-dep-with-root",
+        None,
+        "integration-workspace-inverted-order",
+        false,
+        None,
+    ));
+}
+
 #[test]
 // Sourced from https://pypi.org/project/wasmtime/11.0.0/#files
 // update with wasmtime updates