Skip to main content

uv_build_backend/
lib.rs

1use itertools::Itertools;
2mod metadata;
3mod serde_verbatim;
4mod settings;
5mod source_dist;
6mod wheel;
7
8pub use metadata::{PyProjectToml, check_direct_build};
9pub use settings::{BuildBackendSettings, WheelDataIncludes};
10pub use source_dist::{build_source_dist, list_source_dist};
11use uv_warnings::warn_user_once;
12pub use wheel::{build_editable, build_wheel, list_wheel, metadata};
13
14use std::collections::HashSet;
15use std::ffi::OsStr;
16use std::io;
17use std::path::{Path, PathBuf};
18use std::str::FromStr;
19use thiserror::Error;
20use tracing::debug;
21use walkdir::DirEntry;
22
23use uv_fs::Simplified;
24use uv_globfilter::PortableGlobError;
25use uv_normalize::PackageName;
26use uv_pypi_types::{Identifier, IdentifierParseError};
27
28use crate::metadata::ValidationError;
29use crate::settings::ModuleName;
30
31#[derive(Debug, Error)]
32pub enum Error {
33    #[error(transparent)]
34    Io(#[from] io::Error),
35    #[error("Failed to persist temporary file to {}", _0.user_display())]
36    Persist(PathBuf, #[source] io::Error),
37    #[error("Invalid metadata format in: {}", _0.user_display())]
38    Toml(PathBuf, #[source] toml::de::Error),
39    #[error("Invalid project metadata")]
40    Validation(#[from] ValidationError),
41    #[error("Invalid module name: {0}")]
42    InvalidModuleName(String, #[source] IdentifierParseError),
43    #[error("Unsupported glob expression in: {field}")]
44    PortableGlob {
45        field: String,
46        #[source]
47        source: PortableGlobError,
48    },
49    /// <https://github.com/BurntSushi/ripgrep/discussions/2927>
50    #[error("Glob expressions caused to large regex in: {field}")]
51    GlobSetTooLarge {
52        field: String,
53        #[source]
54        source: globset::Error,
55    },
56    #[error("`pyproject.toml` must not be excluded from source distribution build")]
57    PyprojectTomlExcluded,
58    #[error("Failed to walk source tree: {}", root.user_display())]
59    WalkDir {
60        root: PathBuf,
61        #[source]
62        err: walkdir::Error,
63    },
64    #[error("Failed to write wheel zip archive")]
65    Zip(#[from] zip::result::ZipError),
66    #[error("Failed to write RECORD file")]
67    Csv(#[from] csv::Error),
68    #[error("Failed to write JSON metadata file")]
69    Json(#[source] serde_json::Error),
70    #[error("Expected a Python module at: {}", _0.user_display())]
71    MissingInitPy(PathBuf),
72    #[error("For namespace packages, `__init__.py[i]` is not allowed in parent directory: {}", _0.user_display())]
73    NotANamespace(PathBuf),
74    /// Either an absolute path or a parent path through `..`.
75    #[error("Module root must be inside the project: {}", _0.user_display())]
76    InvalidModuleRoot(PathBuf),
77    /// Either an absolute path or a parent path through `..`.
78    #[error("The path for the data directory {} must be inside the project: {}", name, path.user_display())]
79    InvalidDataRoot { name: String, path: PathBuf },
80    #[error("Virtual environments must not be added to source distributions or wheels, remove the directory or exclude it from the build: {}", _0.user_display())]
81    VenvInSourceTree(PathBuf),
82    #[error("Inconsistent metadata between prepare and build step: {0}")]
83    InconsistentSteps(&'static str),
84    #[error("Failed to write to {}", _0.user_display())]
85    TarWrite(PathBuf, #[source] io::Error),
86}
87
88/// Dispatcher between writing to a directory, writing to a zip, writing to a `.tar.gz` and
89/// listing files.
90///
91/// All paths are string types instead of path types since wheels are portable between platforms.
92///
93/// Contract: You must call close before dropping to obtain a valid output (dropping is fine in the
94/// error case).
95trait DirectoryWriter {
96    /// Add a file with the given content.
97    ///
98    /// Files added through the method are considered generated when listing included files.
99    fn write_bytes(&mut self, path: &str, bytes: &[u8]) -> Result<(), Error>;
100
101    /// Add the file or directory to the path.
102    fn write_dir_entry(&mut self, entry: &DirEntry, target_path: &str) -> Result<(), Error> {
103        if entry.file_type().is_dir() {
104            self.write_directory(target_path)?;
105        } else {
106            self.write_file(target_path, entry.path())?;
107        }
108        Ok(())
109    }
110
111    /// Add a local file.
112    fn write_file(&mut self, path: &str, file: &Path) -> Result<(), Error>;
113
114    /// Create a directory.
115    fn write_directory(&mut self, directory: &str) -> Result<(), Error>;
116
117    /// Write the `RECORD` file and if applicable, the central directory.
118    fn close(self, dist_info_dir: &str) -> Result<(), Error>;
119}
120
121/// Name of the file in the archive and path outside, if it wasn't generated.
122pub(crate) type FileList = Vec<(String, Option<PathBuf>)>;
123
124/// A dummy writer to collect the file names that would be included in a build.
125pub(crate) struct ListWriter<'a> {
126    files: &'a mut FileList,
127}
128
129impl<'a> ListWriter<'a> {
130    /// Convert the writer to the collected file names.
131    pub(crate) fn new(files: &'a mut FileList) -> Self {
132        Self { files }
133    }
134}
135
136impl DirectoryWriter for ListWriter<'_> {
137    fn write_bytes(&mut self, path: &str, _bytes: &[u8]) -> Result<(), Error> {
138        self.files.push((path.to_string(), None));
139        Ok(())
140    }
141
142    fn write_file(&mut self, path: &str, file: &Path) -> Result<(), Error> {
143        self.files
144            .push((path.to_string(), Some(file.to_path_buf())));
145        Ok(())
146    }
147
148    fn write_directory(&mut self, _directory: &str) -> Result<(), Error> {
149        Ok(())
150    }
151
152    fn close(self, _dist_info_dir: &str) -> Result<(), Error> {
153        Ok(())
154    }
155}
156
157/// PEP 517 requires that the metadata directory from the prepare metadata call is identical to the
158/// build wheel call. This method performs a prudence check that `METADATA` and `entry_points.txt`
159/// match.
160fn check_metadata_directory(
161    source_tree: &Path,
162    metadata_directory: Option<&Path>,
163    pyproject_toml: &PyProjectToml,
164) -> Result<(), Error> {
165    let Some(metadata_directory) = metadata_directory else {
166        return Ok(());
167    };
168
169    debug!(
170        "Checking metadata directory {}",
171        metadata_directory.user_display()
172    );
173
174    // `METADATA` is a mandatory file.
175    let current = pyproject_toml
176        .to_metadata(source_tree)?
177        .core_metadata_format();
178    let previous = fs_err::read_to_string(metadata_directory.join("METADATA"))?;
179    if previous != current {
180        return Err(Error::InconsistentSteps("METADATA"));
181    }
182
183    // `entry_points.txt` is not written if it would be empty.
184    let entrypoints_path = metadata_directory.join("entry_points.txt");
185    match pyproject_toml.to_entry_points()? {
186        None => {
187            if entrypoints_path.is_file() {
188                return Err(Error::InconsistentSteps("entry_points.txt"));
189            }
190        }
191        Some(entrypoints) => {
192            if fs_err::read_to_string(&entrypoints_path)? != entrypoints {
193                return Err(Error::InconsistentSteps("entry_points.txt"));
194            }
195        }
196    }
197
198    Ok(())
199}
200
201/// Returns the list of module names without names which would be included twice
202///
203/// In normal cases it should do nothing:
204///
205/// * `["aaa"] -> ["aaa"]`
206/// * `["aaa", "bbb"] -> ["aaa", "bbb"]`
207///
208/// Duplicate elements are removed:
209///
210/// * `["aaa", "aaa"] -> ["aaa"]`
211/// * `["bbb", "aaa", "bbb"] -> ["aaa", "bbb"]`
212///
213/// Names with more specific paths are removed in favour of more general paths:
214///
215/// * `["aaa.foo", "aaa"] -> ["aaa"]`
216/// * `["bbb", "aaa", "bbb.foo", "ccc.foo", "ccc.foo.bar", "aaa"] -> ["aaa", "bbb.foo", "ccc.foo"]`
217///
218/// This does not preserve the order of the elements.
219fn prune_redundant_modules(mut names: Vec<String>) -> Vec<String> {
220    names.sort();
221    let mut pruned = Vec::with_capacity(names.len());
222    for name in names {
223        if let Some(last) = pruned.last() {
224            if name == *last {
225                continue;
226            }
227            // This is a more specific (narrow) module name than what came before
228            if name
229                .strip_prefix(last)
230                .is_some_and(|suffix| suffix.starts_with('.'))
231            {
232                continue;
233            }
234        }
235        pruned.push(name);
236    }
237    pruned
238}
239
240/// Wraps [`prune_redundant_modules`] with a conditional warning when modules are ignored
241fn prune_redundant_modules_warn(names: &[String], show_warnings: bool) -> Vec<String> {
242    let pruned = prune_redundant_modules(names.to_vec());
243    if show_warnings && names.len() != pruned.len() {
244        let mut pruned: HashSet<_> = pruned.iter().collect();
245        let ignored: Vec<_> = names.iter().filter(|name| !pruned.remove(name)).collect();
246        let s = if ignored.len() == 1 { "" } else { "s" };
247        warn_user_once!(
248            "Ignoring redundant module name{s} in `tool.uv.build-backend.module-name`: `{}`",
249            ignored.into_iter().join("`, `")
250        );
251    }
252    pruned
253}
254
255/// Returns the source root and the module path(s) with the `__init__.py[i]`  below to it while
256/// checking the project layout and names.
257///
258/// Some target platforms have case-sensitive filesystems, while others have case-insensitive
259/// filesystems. We always lower case the package name, our default for the module, while some
260/// users want uppercase letters in their module names. For example, the package name is `pil_util`,
261/// but the module `PIL_util`. To make the behavior as consistent as possible across platforms as
262/// possible, we require that an upper case name is given explicitly through
263/// `tool.uv.build-backend.module-name`.
264///
265/// By default, the dist-info-normalized package name is the module name. For
266/// dist-info-normalization, the rules are lowercasing, replacing `.` with `_` and
267/// replace `-` with `_`. Since `.` and `-` are not allowed in identifiers, we can use a string
268/// comparison with the module name.
269///
270/// While we recommend one module per package, it is possible to declare a list of modules.
271fn find_roots(
272    source_tree: &Path,
273    pyproject_toml: &PyProjectToml,
274    relative_module_root: &Path,
275    module_name: Option<&ModuleName>,
276    namespace: bool,
277    show_warnings: bool,
278) -> Result<(PathBuf, Vec<PathBuf>), Error> {
279    let relative_module_root = uv_fs::normalize_path(relative_module_root);
280    // Check that even if a path contains `..`, we only include files below the module root.
281    if !uv_fs::normalize_path(&source_tree.join(&relative_module_root))
282        .starts_with(uv_fs::normalize_path(source_tree))
283    {
284        return Err(Error::InvalidModuleRoot(relative_module_root.to_path_buf()));
285    }
286    let src_root = source_tree.join(&relative_module_root);
287    debug!("Source root: {}", src_root.user_display());
288
289    if namespace {
290        // `namespace = true` disables module structure checks.
291        let modules_relative = if let Some(module_name) = module_name {
292            match module_name {
293                ModuleName::Name(name) => {
294                    vec![name.split('.').collect::<PathBuf>()]
295                }
296                ModuleName::Names(names) => prune_redundant_modules_warn(names, show_warnings)
297                    .into_iter()
298                    .map(|name| name.split('.').collect::<PathBuf>())
299                    .collect(),
300            }
301        } else {
302            vec![PathBuf::from(
303                pyproject_toml.name().as_dist_info_name().to_string(),
304            )]
305        };
306        for module_relative in &modules_relative {
307            debug!("Namespace module path: {}", module_relative.user_display());
308        }
309        return Ok((src_root, modules_relative));
310    }
311
312    let modules_relative = if let Some(module_name) = module_name {
313        match module_name {
314            ModuleName::Name(name) => vec![module_path_from_module_name(&src_root, name)?],
315            ModuleName::Names(names) => prune_redundant_modules_warn(names, show_warnings)
316                .into_iter()
317                .map(|name| module_path_from_module_name(&src_root, &name))
318                .collect::<Result<_, _>>()?,
319        }
320    } else {
321        vec![find_module_path_from_package_name(
322            &src_root,
323            pyproject_toml.name(),
324        )?]
325    };
326    for module_relative in &modules_relative {
327        debug!("Module path: {}", module_relative.user_display());
328    }
329    Ok((src_root, modules_relative))
330}
331
332/// Infer stubs packages from package name alone.
333///
334/// There are potential false positives if someone had a regular package with `-stubs`.
335/// The `Identifier` checks in `module_path_from_module_name` are here covered by the `PackageName`
336/// validation.
337fn find_module_path_from_package_name(
338    src_root: &Path,
339    package_name: &PackageName,
340) -> Result<PathBuf, Error> {
341    if let Some(stem) = package_name.to_string().strip_suffix("-stubs") {
342        debug!("Building stubs package instead of a regular package");
343        let module_name = PackageName::from_str(stem)
344            .expect("non-empty package name prefix must be valid package name")
345            .as_dist_info_name()
346            .to_string();
347        let module_relative = PathBuf::from(format!("{module_name}-stubs"));
348        let init_pyi = src_root.join(&module_relative).join("__init__.pyi");
349        if !init_pyi.is_file() {
350            return Err(Error::MissingInitPy(init_pyi));
351        }
352        Ok(module_relative)
353    } else {
354        // This name is always lowercase.
355        let module_relative = PathBuf::from(package_name.as_dist_info_name().to_string());
356        let init_py = src_root.join(&module_relative).join("__init__.py");
357        if !init_py.is_file() {
358            return Err(Error::MissingInitPy(init_py));
359        }
360        Ok(module_relative)
361    }
362}
363
364/// Determine the relative module path from an explicit module name.
365fn module_path_from_module_name(src_root: &Path, module_name: &str) -> Result<PathBuf, Error> {
366    // This name can be uppercase.
367    let module_relative = module_name.split('.').collect::<PathBuf>();
368
369    // Check if we have a regular module or a namespace.
370    let (root_name, namespace_segments) =
371        if let Some((root_name, namespace_segments)) = module_name.split_once('.') {
372            (
373                root_name,
374                namespace_segments.split('.').collect::<Vec<&str>>(),
375            )
376        } else {
377            (module_name, Vec::new())
378        };
379
380    // Check if we have an implementation or a stubs package.
381    // For stubs for a namespace, the `-stubs` prefix must be on the root.
382    let stubs = if let Some(stem) = root_name.strip_suffix("-stubs") {
383        // Check that the stubs belong to a valid module.
384        Identifier::from_str(stem)
385            .map_err(|err| Error::InvalidModuleName(module_name.to_string(), err))?;
386        true
387    } else {
388        Identifier::from_str(root_name)
389            .map_err(|err| Error::InvalidModuleName(module_name.to_string(), err))?;
390        false
391    };
392
393    // For a namespace, check that all names below the root is valid.
394    for segment in namespace_segments {
395        Identifier::from_str(segment)
396            .map_err(|err| Error::InvalidModuleName(module_name.to_string(), err))?;
397    }
398
399    // Check that an `__init__.py[i]` exists for the module.
400    let init_py =
401        src_root
402            .join(&module_relative)
403            .join(if stubs { "__init__.pyi" } else { "__init__.py" });
404    if !init_py.is_file() {
405        return Err(Error::MissingInitPy(init_py));
406    }
407
408    // For a namespace, check that the directories above the lowest are namespace directories.
409    for namespace_dir in module_relative.ancestors().skip(1) {
410        if src_root.join(namespace_dir).join("__init__.py").exists()
411            || src_root.join(namespace_dir).join("__init__.pyi").exists()
412        {
413            return Err(Error::NotANamespace(src_root.join(namespace_dir)));
414        }
415    }
416
417    Ok(module_relative)
418}
419
420/// Error if we're adding a venv to a distribution.
421pub(crate) fn error_on_venv(file_name: &OsStr, path: &Path) -> Result<(), Error> {
422    // On 64-bit Unix, `lib64` is a (compatibility) symlink to lib. If we traverse `lib64` before
423    // `pyvenv.cfg`, we show a generic error for symlink directories instead.
424    if !(file_name == "pyvenv.cfg" || file_name == "lib64") {
425        return Ok(());
426    }
427
428    let Some(parent) = path.parent() else {
429        return Ok(());
430    };
431
432    if parent.join("bin").join("python").is_symlink()
433        || parent.join("Scripts").join("python.exe").is_file()
434    {
435        return Err(Error::VenvInSourceTree(parent.to_path_buf()));
436    }
437
438    Ok(())
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use flate2::bufread::GzDecoder;
445    use fs_err::File;
446    use indoc::indoc;
447    use insta::assert_snapshot;
448    use itertools::Itertools;
449    use regex::Regex;
450    use sha2::Digest;
451    use std::io::{BufReader, Read};
452    use std::iter;
453    use tempfile::TempDir;
454    use uv_distribution_filename::{SourceDistFilename, WheelFilename};
455    use uv_fs::{copy_dir_all, relative_to};
456    use uv_preview::{Preview, PreviewFeature};
457
458    const MOCK_UV_VERSION: &str = "1.0.0+test";
459
460    fn format_err(err: &Error) -> String {
461        let context = iter::successors(std::error::Error::source(&err), |&err| err.source())
462            .map(|err| format!("  Caused by: {err}"))
463            .join("\n");
464        err.to_string() + "\n" + &context
465    }
466
467    /// File listings, generated archives and archive contents for both a build with
468    /// source tree -> wheel
469    /// and a build with
470    /// source tree -> source dist -> wheel.
471    #[derive(Debug, PartialEq, Eq)]
472    struct BuildResults {
473        source_dist_list_files: FileList,
474        source_dist_filename: SourceDistFilename,
475        source_dist_contents: Vec<String>,
476        wheel_list_files: FileList,
477        wheel_filename: WheelFilename,
478        wheel_contents: Vec<String>,
479    }
480
481    /// Run both a direct wheel build and an indirect wheel build through a source distribution,
482    /// while checking that directly built wheel and indirectly built wheel are the same.
483    fn build(source_root: &Path, dist: &Path, preview: Preview) -> Result<BuildResults, Error> {
484        // Build a direct wheel, capture all its properties to compare it with the indirect wheel
485        // latest and remove it since it has the same filename as the indirect wheel.
486        let (_name, direct_wheel_list_files) =
487            list_wheel(source_root, MOCK_UV_VERSION, false, preview)?;
488        let direct_wheel_filename =
489            build_wheel(source_root, dist, None, MOCK_UV_VERSION, false, preview)?;
490        let direct_wheel_path = dist.join(direct_wheel_filename.to_string());
491        let direct_wheel_contents = wheel_contents(&direct_wheel_path);
492        let direct_wheel_hash = sha2::Sha256::digest(fs_err::read(&direct_wheel_path)?);
493        fs_err::remove_file(&direct_wheel_path)?;
494
495        // Build a source distribution.
496        let (_name, source_dist_list_files) =
497            list_source_dist(source_root, MOCK_UV_VERSION, false)?;
498        // TODO(konsti): This should run in the unpacked source dist tempdir, but we need to
499        // normalize the path.
500        let (_name, wheel_list_files) = list_wheel(source_root, MOCK_UV_VERSION, false, preview)?;
501        let source_dist_filename = build_source_dist(source_root, dist, MOCK_UV_VERSION, false)?;
502        let source_dist_path = dist.join(source_dist_filename.to_string());
503        let source_dist_contents = sdist_contents(&source_dist_path);
504
505        // Unpack the source distribution and build a wheel from it.
506        let sdist_tree = TempDir::new()?;
507        let sdist_reader = BufReader::new(File::open(&source_dist_path)?);
508        let mut source_dist = tar::Archive::new(GzDecoder::new(sdist_reader));
509        source_dist.unpack(sdist_tree.path())?;
510        let sdist_top_level_directory = sdist_tree.path().join(format!(
511            "{}-{}",
512            source_dist_filename.name.as_dist_info_name(),
513            source_dist_filename.version
514        ));
515        let wheel_filename = build_wheel(
516            &sdist_top_level_directory,
517            dist,
518            None,
519            MOCK_UV_VERSION,
520            false,
521            preview,
522        )?;
523        let wheel_contents = wheel_contents(&dist.join(wheel_filename.to_string()));
524
525        // Check that direct and indirect wheels are identical.
526        assert_eq!(direct_wheel_filename, wheel_filename);
527        assert_eq!(direct_wheel_contents, wheel_contents);
528        assert_eq!(direct_wheel_list_files, wheel_list_files);
529        assert_eq!(
530            direct_wheel_hash,
531            sha2::Sha256::digest(fs_err::read(dist.join(wheel_filename.to_string()))?)
532        );
533
534        Ok(BuildResults {
535            source_dist_list_files,
536            source_dist_filename,
537            source_dist_contents,
538            wheel_list_files,
539            wheel_filename,
540            wheel_contents,
541        })
542    }
543
544    fn build_err(source_root: &Path) -> String {
545        let dist = TempDir::new().unwrap();
546        let build_err = build(source_root, dist.path(), Preview::default()).unwrap_err();
547        let err_message: String = format_err(&build_err)
548            .replace(&source_root.user_display().to_string(), "[TEMP_PATH]")
549            .replace('\\', "/");
550        err_message
551    }
552
553    fn sdist_contents(source_dist_path: &Path) -> Vec<String> {
554        let sdist_reader = BufReader::new(File::open(source_dist_path).unwrap());
555        let mut source_dist = tar::Archive::new(GzDecoder::new(sdist_reader));
556        let mut source_dist_contents: Vec<_> = source_dist
557            .entries()
558            .unwrap()
559            .map(|entry| {
560                entry
561                    .unwrap()
562                    .path()
563                    .unwrap()
564                    .to_str()
565                    .unwrap()
566                    .replace('\\', "/")
567            })
568            .collect();
569        source_dist_contents.sort();
570        source_dist_contents
571    }
572
573    fn wheel_contents(direct_output_dir: &Path) -> Vec<String> {
574        let wheel = zip::ZipArchive::new(File::open(direct_output_dir).unwrap()).unwrap();
575        let mut wheel_contents: Vec<_> = wheel
576            .file_names()
577            .map(|path| path.replace('\\', "/"))
578            .collect();
579        wheel_contents.sort_unstable();
580        wheel_contents
581    }
582
583    fn format_file_list(file_list: FileList, src: &Path) -> String {
584        file_list
585            .into_iter()
586            .map(|(path, source)| {
587                let path = path.replace('\\', "/");
588                if let Some(source) = source {
589                    let source = relative_to(source, src)
590                        .unwrap()
591                        .portable_display()
592                        .to_string();
593                    format!("{path} ({source})")
594                } else {
595                    format!("{path} (generated)")
596                }
597            })
598            .join("\n")
599    }
600
601    /// Tests that builds are stable and include the right files and.
602    ///
603    /// Tests that both source tree -> source dist -> wheel and source tree -> wheel include the
604    /// right files. Also checks that the resulting archives are byte-by-byte identical
605    /// independent of the build path or platform, with the caveat that we cannot serialize an
606    /// executable bit on Window. This ensures reproducible builds and best-effort
607    /// platform-independent deterministic builds.
608    #[test]
609    fn built_by_uv_building() {
610        let built_by_uv = Path::new("../../test/packages/built-by-uv");
611        let src = TempDir::new().unwrap();
612        for dir in [
613            "src",
614            "tests",
615            "data-dir",
616            "third-party-licenses",
617            "assets",
618            "header",
619            "scripts",
620        ] {
621            copy_dir_all(built_by_uv.join(dir), src.path().join(dir)).unwrap();
622        }
623        for filename in [
624            "pyproject.toml",
625            "README.md",
626            "uv.lock",
627            "LICENSE-APACHE",
628            "LICENSE-MIT",
629        ] {
630            fs_err::copy(built_by_uv.join(filename), src.path().join(filename)).unwrap();
631        }
632
633        // Clear executable bit on Unix to build the same archive between Unix and Windows.
634        // This is a caveat to the determinism of the uv build backend: When a file has the
635        // executable in the source repository, it only has the executable bit on Unix, as Windows
636        // does not have the concept of the executable bit.
637        #[cfg(unix)]
638        {
639            use std::os::unix::fs::PermissionsExt;
640            let path = src.path().join("scripts").join("whoami.sh");
641            let metadata = fs_err::metadata(&path).unwrap();
642            let mut perms = metadata.permissions();
643            perms.set_mode(perms.mode() & !0o111);
644            fs_err::set_permissions(&path, perms).unwrap();
645        }
646
647        // Redact the uv_build version to keep the hash stable across releases
648        let pyproject_toml = fs_err::read_to_string(src.path().join("pyproject.toml")).unwrap();
649        let current_requires =
650            Regex::new(r#"requires = \["uv_build>=[0-9.]+,<[0-9.]+"\]"#).unwrap();
651        let mocked_requires = r#"requires = ["uv_build>=1,<2"]"#;
652        let pyproject_toml = current_requires.replace(pyproject_toml.as_str(), mocked_requires);
653        fs_err::write(src.path().join("pyproject.toml"), pyproject_toml.as_bytes()).unwrap();
654
655        // Add some files to be excluded
656        let module_root = src.path().join("src").join("built_by_uv");
657        fs_err::create_dir_all(module_root.join("__pycache__")).unwrap();
658        File::create(module_root.join("__pycache__").join("compiled.pyc")).unwrap();
659        File::create(module_root.join("arithmetic").join("circle.pyc")).unwrap();
660
661        // Perform both the direct and the indirect build.
662        let dist = TempDir::new().unwrap();
663        let build = build(src.path(), dist.path(), Preview::default()).unwrap();
664
665        let source_dist_path = dist.path().join(build.source_dist_filename.to_string());
666        assert_eq!(
667            build.source_dist_filename.to_string(),
668            "built_by_uv-0.1.0.tar.gz"
669        );
670        // Check that the source dist is reproducible across platforms.
671        assert_snapshot!(
672            format!("{:x}", sha2::Sha256::digest(fs_err::read(&source_dist_path).unwrap())),
673            @"bb74bff575b135bb39e5c9bce56349441fb0923bb8857e32a5eaf34ec1843967"
674        );
675        // Check both the files we report and the actual files
676        assert_snapshot!(format_file_list(build.source_dist_list_files, src.path()), @"
677        built_by_uv-0.1.0/PKG-INFO (generated)
678        built_by_uv-0.1.0/LICENSE-APACHE (LICENSE-APACHE)
679        built_by_uv-0.1.0/LICENSE-MIT (LICENSE-MIT)
680        built_by_uv-0.1.0/README.md (README.md)
681        built_by_uv-0.1.0/assets/data.csv (assets/data.csv)
682        built_by_uv-0.1.0/header/built_by_uv.h (header/built_by_uv.h)
683        built_by_uv-0.1.0/pyproject.toml (pyproject.toml)
684        built_by_uv-0.1.0/scripts/whoami.sh (scripts/whoami.sh)
685        built_by_uv-0.1.0/src/built_by_uv/__init__.py (src/built_by_uv/__init__.py)
686        built_by_uv-0.1.0/src/built_by_uv/arithmetic/__init__.py (src/built_by_uv/arithmetic/__init__.py)
687        built_by_uv-0.1.0/src/built_by_uv/arithmetic/circle.py (src/built_by_uv/arithmetic/circle.py)
688        built_by_uv-0.1.0/src/built_by_uv/arithmetic/pi.txt (src/built_by_uv/arithmetic/pi.txt)
689        built_by_uv-0.1.0/src/built_by_uv/build-only.h (src/built_by_uv/build-only.h)
690        built_by_uv-0.1.0/src/built_by_uv/cli.py (src/built_by_uv/cli.py)
691        built_by_uv-0.1.0/third-party-licenses/PEP-401.txt (third-party-licenses/PEP-401.txt)
692        ");
693        assert_snapshot!(build.source_dist_contents.iter().join("\n"), @"
694        built_by_uv-0.1.0/
695        built_by_uv-0.1.0/LICENSE-APACHE
696        built_by_uv-0.1.0/LICENSE-MIT
697        built_by_uv-0.1.0/PKG-INFO
698        built_by_uv-0.1.0/README.md
699        built_by_uv-0.1.0/assets
700        built_by_uv-0.1.0/assets/data.csv
701        built_by_uv-0.1.0/header
702        built_by_uv-0.1.0/header/built_by_uv.h
703        built_by_uv-0.1.0/pyproject.toml
704        built_by_uv-0.1.0/scripts
705        built_by_uv-0.1.0/scripts/whoami.sh
706        built_by_uv-0.1.0/src
707        built_by_uv-0.1.0/src/built_by_uv
708        built_by_uv-0.1.0/src/built_by_uv/__init__.py
709        built_by_uv-0.1.0/src/built_by_uv/arithmetic
710        built_by_uv-0.1.0/src/built_by_uv/arithmetic/__init__.py
711        built_by_uv-0.1.0/src/built_by_uv/arithmetic/circle.py
712        built_by_uv-0.1.0/src/built_by_uv/arithmetic/pi.txt
713        built_by_uv-0.1.0/src/built_by_uv/build-only.h
714        built_by_uv-0.1.0/src/built_by_uv/cli.py
715        built_by_uv-0.1.0/third-party-licenses
716        built_by_uv-0.1.0/third-party-licenses/PEP-401.txt
717        ");
718
719        let wheel_path = dist.path().join(build.wheel_filename.to_string());
720        assert_eq!(
721            build.wheel_filename.to_string(),
722            "built_by_uv-0.1.0-py3-none-any.whl"
723        );
724        // Check that the wheel is reproducible across platforms.
725        assert_snapshot!(
726            format!("{:x}", sha2::Sha256::digest(fs_err::read(&wheel_path).unwrap())),
727            @"dbe56fd8bd52184095b2e0ea3e83c95d1bc8b4aa53cf469cec5af62251b24abb"
728        );
729        assert_snapshot!(build.wheel_contents.join("\n"), @"
730        built_by_uv-0.1.0.data/data/
731        built_by_uv-0.1.0.data/data/data.csv
732        built_by_uv-0.1.0.data/headers/
733        built_by_uv-0.1.0.data/headers/built_by_uv.h
734        built_by_uv-0.1.0.data/scripts/
735        built_by_uv-0.1.0.data/scripts/whoami.sh
736        built_by_uv-0.1.0.dist-info/
737        built_by_uv-0.1.0.dist-info/METADATA
738        built_by_uv-0.1.0.dist-info/RECORD
739        built_by_uv-0.1.0.dist-info/WHEEL
740        built_by_uv-0.1.0.dist-info/entry_points.txt
741        built_by_uv-0.1.0.dist-info/licenses/
742        built_by_uv-0.1.0.dist-info/licenses/LICENSE-APACHE
743        built_by_uv-0.1.0.dist-info/licenses/LICENSE-MIT
744        built_by_uv-0.1.0.dist-info/licenses/third-party-licenses/
745        built_by_uv-0.1.0.dist-info/licenses/third-party-licenses/PEP-401.txt
746        built_by_uv/
747        built_by_uv/__init__.py
748        built_by_uv/arithmetic/
749        built_by_uv/arithmetic/__init__.py
750        built_by_uv/arithmetic/circle.py
751        built_by_uv/arithmetic/pi.txt
752        built_by_uv/cli.py
753        ");
754        assert_snapshot!(format_file_list(build.wheel_list_files, src.path()), @"
755        built_by_uv/__init__.py (src/built_by_uv/__init__.py)
756        built_by_uv/arithmetic/__init__.py (src/built_by_uv/arithmetic/__init__.py)
757        built_by_uv/arithmetic/circle.py (src/built_by_uv/arithmetic/circle.py)
758        built_by_uv/arithmetic/pi.txt (src/built_by_uv/arithmetic/pi.txt)
759        built_by_uv/cli.py (src/built_by_uv/cli.py)
760        built_by_uv-0.1.0.dist-info/licenses/LICENSE-APACHE (LICENSE-APACHE)
761        built_by_uv-0.1.0.dist-info/licenses/LICENSE-MIT (LICENSE-MIT)
762        built_by_uv-0.1.0.dist-info/licenses/third-party-licenses/PEP-401.txt (third-party-licenses/PEP-401.txt)
763        built_by_uv-0.1.0.data/headers/built_by_uv.h (header/built_by_uv.h)
764        built_by_uv-0.1.0.data/scripts/whoami.sh (scripts/whoami.sh)
765        built_by_uv-0.1.0.data/data/data.csv (assets/data.csv)
766        built_by_uv-0.1.0.dist-info/WHEEL (generated)
767        built_by_uv-0.1.0.dist-info/entry_points.txt (generated)
768        built_by_uv-0.1.0.dist-info/METADATA (generated)
769        ");
770
771        let mut wheel = zip::ZipArchive::new(File::open(wheel_path).unwrap()).unwrap();
772        let mut record = String::new();
773        wheel
774            .by_name("built_by_uv-0.1.0.dist-info/RECORD")
775            .unwrap()
776            .read_to_string(&mut record)
777            .unwrap();
778        assert_snapshot!(record, @"
779        built_by_uv/__init__.py,sha256=AJ7XpTNWxYktP97ydb81UpnNqoebH7K4sHRakAMQKG4,44
780        built_by_uv/arithmetic/__init__.py,sha256=x2agwFbJAafc9Z6TdJ0K6b6bLMApQdvRSQjP4iy7IEI,67
781        built_by_uv/arithmetic/circle.py,sha256=FYZkv6KwrF9nJcwGOKigjke1dm1Fkie7qW1lWJoh3AE,287
782        built_by_uv/arithmetic/pi.txt,sha256=-4HqoLoIrSKGf0JdTrM8BTTiIz8rq-MSCDL6LeF0iuU,8
783        built_by_uv/cli.py,sha256=Jcm3PxSb8wTAN3dGm5vKEDQwCgoUXkoeggZeF34QyKM,44
784        built_by_uv-0.1.0.dist-info/licenses/LICENSE-APACHE,sha256=QwcOLU5TJoTeUhuIXzhdCEEDDvorGiC6-3YTOl4TecE,11356
785        built_by_uv-0.1.0.dist-info/licenses/LICENSE-MIT,sha256=F5Z0Cpu8QWyblXwXhrSo0b9WmYXQxd1LwLjVLJZwbiI,1077
786        built_by_uv-0.1.0.dist-info/licenses/third-party-licenses/PEP-401.txt,sha256=KN-KAx829G2saLjVmByc08RFFtIDWvHulqPyD0qEBZI,270
787        built_by_uv-0.1.0.data/headers/built_by_uv.h,sha256=p5-HBunJ1dY-xd4dMn03PnRClmGyRosScIp8rT46kg4,144
788        built_by_uv-0.1.0.data/scripts/whoami.sh,sha256=T2cmhuDFuX-dTkiSkuAmNyIzvv8AKopjnuTCcr9o-eE,20
789        built_by_uv-0.1.0.data/data/data.csv,sha256=7z7u-wXu7Qr2eBZFVpBILlNUiGSngv_1vYqZHVWOU94,265
790        built_by_uv-0.1.0.dist-info/WHEEL,sha256=JBpLtoa_WBz5WPGpRsAUTD4Dz6H0KkkdiKWCkfMSS1U,84
791        built_by_uv-0.1.0.dist-info/entry_points.txt,sha256=-IO6yaq6x6HSl-zWH96rZmgYvfyHlH00L5WQoCpz-YI,50
792        built_by_uv-0.1.0.dist-info/METADATA,sha256=m6EkVvKrGmqx43b_VR45LHD37IZxPYC0NI6Qx9_UXLE,474
793        built_by_uv-0.1.0.dist-info/RECORD,,
794        ");
795    }
796
797    /// Test that `license = { file = "LICENSE" }` is supported.
798    #[test]
799    fn license_file_pre_pep639() {
800        let src = TempDir::new().unwrap();
801        fs_err::write(
802            src.path().join("pyproject.toml"),
803            indoc! {r#"
804            [project]
805            name = "pep-pep639-license"
806            version = "1.0.0"
807            license = { file = "license.txt" }
808
809            [build-system]
810            requires = ["uv_build>=0.5.15,<0.6.0"]
811            build-backend = "uv_build"
812        "#
813            },
814        )
815        .unwrap();
816        fs_err::create_dir_all(src.path().join("src").join("pep_pep639_license")).unwrap();
817        File::create(
818            src.path()
819                .join("src")
820                .join("pep_pep639_license")
821                .join("__init__.py"),
822        )
823        .unwrap();
824        fs_err::write(
825            src.path().join("license.txt"),
826            "Copy carefully.\nSincerely, the authors",
827        )
828        .unwrap();
829
830        // Build a wheel from a source distribution
831        let output_dir = TempDir::new().unwrap();
832        build_source_dist(src.path(), output_dir.path(), "0.5.15", false).unwrap();
833        let sdist_tree = TempDir::new().unwrap();
834        let source_dist_path = output_dir.path().join("pep_pep639_license-1.0.0.tar.gz");
835        let sdist_reader = BufReader::new(File::open(&source_dist_path).unwrap());
836        let mut source_dist = tar::Archive::new(GzDecoder::new(sdist_reader));
837        source_dist.unpack(sdist_tree.path()).unwrap();
838        build_wheel(
839            &sdist_tree.path().join("pep_pep639_license-1.0.0"),
840            output_dir.path(),
841            None,
842            "0.5.15",
843            false,
844            Preview::default(),
845        )
846        .unwrap();
847        let wheel = output_dir
848            .path()
849            .join("pep_pep639_license-1.0.0-py3-none-any.whl");
850        let mut wheel = zip::ZipArchive::new(File::open(wheel).unwrap()).unwrap();
851
852        let mut metadata = String::new();
853        wheel
854            .by_name("pep_pep639_license-1.0.0.dist-info/METADATA")
855            .unwrap()
856            .read_to_string(&mut metadata)
857            .unwrap();
858
859        assert_snapshot!(metadata, @"
860        Metadata-Version: 2.3
861        Name: pep-pep639-license
862        Version: 1.0.0
863        License: Copy carefully.
864                 Sincerely, the authors
865        ");
866    }
867
868    /// Test that `build_wheel` works after the `prepare_metadata_for_build_wheel` hook.
869    #[test]
870    fn prepare_metadata_then_build_wheel() {
871        let src = TempDir::new().unwrap();
872        fs_err::write(
873            src.path().join("pyproject.toml"),
874            indoc! {r#"
875            [project]
876            name = "two-step-build"
877            version = "1.0.0"
878
879            [build-system]
880            requires = ["uv_build>=0.5.15,<0.6.0"]
881            build-backend = "uv_build"
882        "#
883            },
884        )
885        .unwrap();
886        fs_err::create_dir_all(src.path().join("src").join("two_step_build")).unwrap();
887        File::create(
888            src.path()
889                .join("src")
890                .join("two_step_build")
891                .join("__init__.py"),
892        )
893        .unwrap();
894
895        // Prepare the metadata.
896        let metadata_dir = TempDir::new().unwrap();
897        let dist_info_dir = metadata(
898            src.path(),
899            metadata_dir.path(),
900            "0.5.15",
901            Preview::default(),
902        )
903        .unwrap();
904        let metadata_prepared =
905            fs_err::read_to_string(metadata_dir.path().join(&dist_info_dir).join("METADATA"))
906                .unwrap();
907
908        // Build the wheel, using the prepared metadata directory.
909        let output_dir = TempDir::new().unwrap();
910        build_wheel(
911            src.path(),
912            output_dir.path(),
913            Some(&metadata_dir.path().join(&dist_info_dir)),
914            "0.5.15",
915            false,
916            Preview::default(),
917        )
918        .unwrap();
919        let wheel = output_dir
920            .path()
921            .join("two_step_build-1.0.0-py3-none-any.whl");
922        let mut wheel = zip::ZipArchive::new(File::open(wheel).unwrap()).unwrap();
923
924        let mut metadata_wheel = String::new();
925        wheel
926            .by_name("two_step_build-1.0.0.dist-info/METADATA")
927            .unwrap()
928            .read_to_string(&mut metadata_wheel)
929            .unwrap();
930
931        assert_eq!(metadata_prepared, metadata_wheel);
932
933        assert_snapshot!(metadata_wheel, @"
934        Metadata-Version: 2.3
935        Name: two-step-build
936        Version: 1.0.0
937        ");
938    }
939
940    /// Check that non-normalized paths for `module-root` work with the glob inclusions.
941    #[test]
942    fn test_glob_path_normalization() {
943        let src = TempDir::new().unwrap();
944        fs_err::write(
945            src.path().join("pyproject.toml"),
946            indoc! {r#"
947            [project]
948            name = "two-step-build"
949            version = "1.0.0"
950
951            [build-system]
952            requires = ["uv_build>=0.5.15,<0.6.0"]
953            build-backend = "uv_build"
954
955            [tool.uv.build-backend]
956            module-root = "./"
957            "#
958            },
959        )
960        .unwrap();
961
962        fs_err::create_dir_all(src.path().join("two_step_build")).unwrap();
963        File::create(src.path().join("two_step_build").join("__init__.py")).unwrap();
964
965        let dist = TempDir::new().unwrap();
966        let build1 = build(src.path(), dist.path(), Preview::default()).unwrap();
967
968        assert_snapshot!(build1.source_dist_contents.join("\n"), @"
969        two_step_build-1.0.0/
970        two_step_build-1.0.0/PKG-INFO
971        two_step_build-1.0.0/pyproject.toml
972        two_step_build-1.0.0/two_step_build
973        two_step_build-1.0.0/two_step_build/__init__.py
974        ");
975
976        assert_snapshot!(build1.wheel_contents.join("\n"), @"
977        two_step_build-1.0.0.dist-info/
978        two_step_build-1.0.0.dist-info/METADATA
979        two_step_build-1.0.0.dist-info/RECORD
980        two_step_build-1.0.0.dist-info/WHEEL
981        two_step_build/
982        two_step_build/__init__.py
983        ");
984
985        // A path with a parent reference.
986        fs_err::write(
987            src.path().join("pyproject.toml"),
988            indoc! {r#"
989            [project]
990            name = "two-step-build"
991            version = "1.0.0"
992
993            [build-system]
994            requires = ["uv_build>=0.5.15,<0.6.0"]
995            build-backend = "uv_build"
996
997            [tool.uv.build-backend]
998            module-root = "two_step_build/.././"
999            "#
1000            },
1001        )
1002        .unwrap();
1003
1004        let dist = TempDir::new().unwrap();
1005        let build2 = build(src.path(), dist.path(), Preview::default()).unwrap();
1006        assert_eq!(build1, build2);
1007    }
1008
1009    /// Check that upper case letters in module names work.
1010    #[test]
1011    fn test_camel_case() {
1012        let src = TempDir::new().unwrap();
1013        let pyproject_toml = indoc! {r#"
1014            [project]
1015            name = "camelcase"
1016            version = "1.0.0"
1017
1018            [build-system]
1019            requires = ["uv_build>=0.5.15,<0.6.0"]
1020            build-backend = "uv_build"
1021
1022            [tool.uv.build-backend]
1023            module-name = "camelCase"
1024            "#
1025        };
1026        fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap();
1027
1028        fs_err::create_dir_all(src.path().join("src").join("camelCase")).unwrap();
1029        File::create(src.path().join("src").join("camelCase").join("__init__.py")).unwrap();
1030
1031        let dist = TempDir::new().unwrap();
1032        let build1 = build(src.path(), dist.path(), Preview::default()).unwrap();
1033
1034        assert_snapshot!(build1.wheel_contents.join("\n"), @"
1035        camelCase/
1036        camelCase/__init__.py
1037        camelcase-1.0.0.dist-info/
1038        camelcase-1.0.0.dist-info/METADATA
1039        camelcase-1.0.0.dist-info/RECORD
1040        camelcase-1.0.0.dist-info/WHEEL
1041        ");
1042
1043        // Check that an explicit wrong casing fails to build.
1044        fs_err::write(
1045            src.path().join("pyproject.toml"),
1046            pyproject_toml.replace("camelCase", "camel_case"),
1047        )
1048        .unwrap();
1049        let build_err = build(src.path(), dist.path(), Preview::default()).unwrap_err();
1050        let err_message = format_err(&build_err)
1051            .replace(&src.path().user_display().to_string(), "[TEMP_PATH]")
1052            .replace('\\', "/");
1053        assert_snapshot!(
1054            err_message,
1055            @"Expected a Python module at: [TEMP_PATH]/src/camel_case/__init__.py"
1056        );
1057    }
1058
1059    /// Test that no partial files are left in dist directory when build fails.
1060    #[test]
1061    fn no_partial_files_on_build_failure() {
1062        let src = TempDir::new().unwrap();
1063
1064        // Create a minimal pyproject.toml without __init__.py (will fail)
1065        fs_err::write(
1066            src.path().join("pyproject.toml"),
1067            indoc! {r#"
1068                [project]
1069                name = "failing-build"
1070                version = "1.0.0"
1071
1072                [build-system]
1073                requires = ["uv_build>=0.5.15,<0.6.0"]
1074                build-backend = "uv_build"
1075            "#},
1076        )
1077        .unwrap();
1078
1079        let dist = TempDir::new().unwrap();
1080
1081        // Source dist build should fail
1082        let sdist_result = build_source_dist(src.path(), dist.path(), MOCK_UV_VERSION, false);
1083        assert!(sdist_result.is_err());
1084
1085        // Wheel build should fail
1086        let wheel_result = build_wheel(
1087            src.path(),
1088            dist.path(),
1089            None,
1090            MOCK_UV_VERSION,
1091            false,
1092            Preview::default(),
1093        );
1094        assert!(wheel_result.is_err());
1095
1096        // dist directory should be empty (no partial files)
1097        let dist_contents: Vec<_> = fs_err::read_dir(dist.path()).unwrap().collect();
1098        assert!(
1099            dist_contents.is_empty(),
1100            "Expected empty dist directory, but found: {dist_contents:?}"
1101        );
1102    }
1103
1104    /// Test that pre-existing files in the dist directory are deleted before build starts.
1105    #[test]
1106    fn existing_files_deleted_on_build_failure() {
1107        let src = TempDir::new().unwrap();
1108
1109        // Create a minimal pyproject.toml without __init__.py (will fail)
1110        fs_err::write(
1111            src.path().join("pyproject.toml"),
1112            indoc! {r#"
1113                [project]
1114                name = "failing-build"
1115                version = "1.0.0"
1116
1117                [build-system]
1118                requires = ["uv_build>=0.5.15,<0.6.0"]
1119                build-backend = "uv_build"
1120            "#},
1121        )
1122        .unwrap();
1123
1124        let dist = TempDir::new().unwrap();
1125
1126        // Create pre-existing files in dist directory
1127        let sdist_path = dist.path().join("failing_build-1.0.0.tar.gz");
1128        let wheel_path = dist.path().join("failing_build-1.0.0-py3-none-any.whl");
1129        let old_content = b"old content";
1130        fs_err::write(&sdist_path, old_content).unwrap();
1131        fs_err::write(&wheel_path, old_content).unwrap();
1132
1133        // Build should fail and delete existing files
1134        let sdist_result = build_source_dist(src.path(), dist.path(), MOCK_UV_VERSION, false);
1135        assert!(sdist_result.is_err());
1136
1137        let wheel_result = build_wheel(
1138            src.path(),
1139            dist.path(),
1140            None,
1141            MOCK_UV_VERSION,
1142            false,
1143            Preview::default(),
1144        );
1145        assert!(wheel_result.is_err());
1146
1147        // Verify pre-existing files were deleted
1148        assert!(
1149            !sdist_path.exists(),
1150            "Pre-existing sdist should have been deleted"
1151        );
1152        assert!(
1153            !wheel_path.exists(),
1154            "Pre-existing wheel should have been deleted"
1155        );
1156    }
1157
1158    /// Test that existing files are overwritten on successful build.
1159    #[test]
1160    fn existing_files_overwritten_on_success() {
1161        let src = TempDir::new().unwrap();
1162
1163        // Create a valid project
1164        fs_err::write(
1165            src.path().join("pyproject.toml"),
1166            indoc! {r#"
1167                [project]
1168                name = "overwrite-test"
1169                version = "1.0.0"
1170
1171                [build-system]
1172                requires = ["uv_build>=0.5.15,<0.6.0"]
1173                build-backend = "uv_build"
1174            "#},
1175        )
1176        .unwrap();
1177        fs_err::create_dir_all(src.path().join("src").join("overwrite_test")).unwrap();
1178        File::create(
1179            src.path()
1180                .join("src")
1181                .join("overwrite_test")
1182                .join("__init__.py"),
1183        )
1184        .unwrap();
1185
1186        let dist = TempDir::new().unwrap();
1187
1188        // Create pre-existing files in dist directory with known content
1189        let sdist_path = dist.path().join("overwrite_test-1.0.0.tar.gz");
1190        let wheel_path = dist.path().join("overwrite_test-1.0.0-py3-none-any.whl");
1191        let old_content = b"old content";
1192        fs_err::write(&sdist_path, old_content).unwrap();
1193        fs_err::write(&wheel_path, old_content).unwrap();
1194
1195        // Build should succeed and overwrite existing files
1196        build_source_dist(src.path(), dist.path(), MOCK_UV_VERSION, false).unwrap();
1197        build_wheel(
1198            src.path(),
1199            dist.path(),
1200            None,
1201            MOCK_UV_VERSION,
1202            false,
1203            Preview::default(),
1204        )
1205        .unwrap();
1206
1207        // Verify files were overwritten (content should be different)
1208        assert_ne!(
1209            &fs_err::read(&sdist_path).unwrap()[..],
1210            &old_content[..],
1211            "Source dist should have been overwritten"
1212        );
1213        assert_ne!(
1214            &fs_err::read(&wheel_path).unwrap()[..],
1215            &old_content[..],
1216            "Wheel should have been overwritten"
1217        );
1218
1219        // Verify the new files are valid archives
1220        assert!(
1221            !sdist_contents(&sdist_path).is_empty(),
1222            "sdist should be a valid archive"
1223        );
1224        assert!(
1225            !wheel_contents(&wheel_path).is_empty(),
1226            "wheel should be a valid archive"
1227        );
1228    }
1229
1230    #[test]
1231    fn invalid_stubs_name() {
1232        let src = TempDir::new().unwrap();
1233        let pyproject_toml = indoc! {r#"
1234            [project]
1235            name = "camelcase"
1236            version = "1.0.0"
1237
1238            [build-system]
1239            requires = ["uv_build>=0.5.15,<0.6.0"]
1240            build-backend = "uv_build"
1241
1242            [tool.uv.build-backend]
1243            module-name = "django@home-stubs"
1244            "#
1245        };
1246        fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap();
1247
1248        let dist = TempDir::new().unwrap();
1249        let build_err = build(src.path(), dist.path(), Preview::default()).unwrap_err();
1250        let err_message = format_err(&build_err);
1251        assert_snapshot!(
1252            err_message,
1253            @"
1254        Invalid module name: django@home-stubs
1255          Caused by: Invalid character `@` at position 7 for identifier `django@home`, expected an underscore or an alphanumeric character
1256        "
1257        );
1258    }
1259
1260    /// Stubs packages use a special name and `__init__.pyi`.
1261    #[test]
1262    fn stubs_package() {
1263        let src = TempDir::new().unwrap();
1264        let pyproject_toml = indoc! {r#"
1265            [project]
1266            name = "stuffed-bird-stubs"
1267            version = "1.0.0"
1268
1269            [build-system]
1270            requires = ["uv_build>=0.5.15,<0.6.0"]
1271            build-backend = "uv_build"
1272            "#
1273        };
1274        fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap();
1275        fs_err::create_dir_all(src.path().join("src").join("stuffed_bird-stubs")).unwrap();
1276        // That's the wrong file, we're expecting a `__init__.pyi`.
1277        let regular_init_py = src
1278            .path()
1279            .join("src")
1280            .join("stuffed_bird-stubs")
1281            .join("__init__.py");
1282        File::create(&regular_init_py).unwrap();
1283
1284        let dist = TempDir::new().unwrap();
1285        let build_err = build(src.path(), dist.path(), Preview::default()).unwrap_err();
1286        let err_message = format_err(&build_err)
1287            .replace(&src.path().user_display().to_string(), "[TEMP_PATH]")
1288            .replace('\\', "/");
1289        assert_snapshot!(
1290            err_message,
1291            @"Expected a Python module at: [TEMP_PATH]/src/stuffed_bird-stubs/__init__.pyi"
1292        );
1293
1294        // Create the correct file
1295        fs_err::remove_file(regular_init_py).unwrap();
1296        File::create(
1297            src.path()
1298                .join("src")
1299                .join("stuffed_bird-stubs")
1300                .join("__init__.pyi"),
1301        )
1302        .unwrap();
1303
1304        let build1 = build(src.path(), dist.path(), Preview::default()).unwrap();
1305        assert_snapshot!(build1.wheel_contents.join("\n"), @"
1306        stuffed_bird-stubs/
1307        stuffed_bird-stubs/__init__.pyi
1308        stuffed_bird_stubs-1.0.0.dist-info/
1309        stuffed_bird_stubs-1.0.0.dist-info/METADATA
1310        stuffed_bird_stubs-1.0.0.dist-info/RECORD
1311        stuffed_bird_stubs-1.0.0.dist-info/WHEEL
1312        ");
1313
1314        // Check that setting the name manually works equally.
1315        let pyproject_toml = indoc! {r#"
1316            [project]
1317            name = "stuffed-bird-stubs"
1318            version = "1.0.0"
1319
1320            [build-system]
1321            requires = ["uv_build>=0.5.15,<0.6.0"]
1322            build-backend = "uv_build"
1323
1324            [tool.uv.build-backend]
1325            module-name = "stuffed_bird-stubs"
1326            "#
1327        };
1328        fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap();
1329
1330        let build2 = build(src.path(), dist.path(), Preview::default()).unwrap();
1331        assert_eq!(build1.wheel_contents, build2.wheel_contents);
1332    }
1333
1334    /// A simple namespace package with a single root `__init__.py`.
1335    #[test]
1336    fn simple_namespace_package() {
1337        let src = TempDir::new().unwrap();
1338        let pyproject_toml = indoc! {r#"
1339            [project]
1340            name = "simple-namespace-part"
1341            version = "1.0.0"
1342
1343            [tool.uv.build-backend]
1344            module-name = "simple_namespace.part"
1345
1346            [build-system]
1347            requires = ["uv_build>=0.5.15,<0.6.0"]
1348            build-backend = "uv_build"
1349            "#
1350        };
1351        fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap();
1352        fs_err::create_dir_all(src.path().join("src").join("simple_namespace").join("part"))
1353            .unwrap();
1354
1355        assert_snapshot!(
1356            build_err(src.path()),
1357            @"Expected a Python module at: [TEMP_PATH]/src/simple_namespace/part/__init__.py"
1358        );
1359
1360        // Create the correct file
1361        File::create(
1362            src.path()
1363                .join("src")
1364                .join("simple_namespace")
1365                .join("part")
1366                .join("__init__.py"),
1367        )
1368        .unwrap();
1369
1370        // For a namespace package, there must not be an `__init__.py` here.
1371        let bogus_init_py = src
1372            .path()
1373            .join("src")
1374            .join("simple_namespace")
1375            .join("__init__.py");
1376        File::create(&bogus_init_py).unwrap();
1377        assert_snapshot!(
1378            build_err(src.path()),
1379            @"For namespace packages, `__init__.py[i]` is not allowed in parent directory: [TEMP_PATH]/src/simple_namespace"
1380        );
1381        fs_err::remove_file(bogus_init_py).unwrap();
1382
1383        let dist = TempDir::new().unwrap();
1384        let build1 = build(src.path(), dist.path(), Preview::default()).unwrap();
1385        assert_snapshot!(build1.source_dist_contents.join("\n"), @"
1386        simple_namespace_part-1.0.0/
1387        simple_namespace_part-1.0.0/PKG-INFO
1388        simple_namespace_part-1.0.0/pyproject.toml
1389        simple_namespace_part-1.0.0/src
1390        simple_namespace_part-1.0.0/src/simple_namespace
1391        simple_namespace_part-1.0.0/src/simple_namespace/part
1392        simple_namespace_part-1.0.0/src/simple_namespace/part/__init__.py
1393        ");
1394        assert_snapshot!(build1.wheel_contents.join("\n"), @"
1395        simple_namespace/
1396        simple_namespace/part/
1397        simple_namespace/part/__init__.py
1398        simple_namespace_part-1.0.0.dist-info/
1399        simple_namespace_part-1.0.0.dist-info/METADATA
1400        simple_namespace_part-1.0.0.dist-info/RECORD
1401        simple_namespace_part-1.0.0.dist-info/WHEEL
1402        ");
1403
1404        // Check that `namespace = true` works too.
1405        let pyproject_toml = indoc! {r#"
1406            [project]
1407            name = "simple-namespace-part"
1408            version = "1.0.0"
1409
1410            [tool.uv.build-backend]
1411            module-name = "simple_namespace.part"
1412            namespace = true
1413
1414            [build-system]
1415            requires = ["uv_build>=0.5.15,<0.6.0"]
1416            build-backend = "uv_build"
1417            "#
1418        };
1419        fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap();
1420
1421        let build2 = build(src.path(), dist.path(), Preview::default()).unwrap();
1422        assert_eq!(build1, build2);
1423    }
1424
1425    /// A complex namespace package with a multiple root `__init__.py`.
1426    #[test]
1427    fn complex_namespace_package() {
1428        let src = TempDir::new().unwrap();
1429        let pyproject_toml = indoc! {r#"
1430            [project]
1431            name = "complex-namespace"
1432            version = "1.0.0"
1433
1434            [tool.uv.build-backend]
1435            namespace = true
1436
1437            [build-system]
1438            requires = ["uv_build>=0.5.15,<0.6.0"]
1439            build-backend = "uv_build"
1440            "#
1441        };
1442        fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap();
1443        fs_err::create_dir_all(
1444            src.path()
1445                .join("src")
1446                .join("complex_namespace")
1447                .join("part_a"),
1448        )
1449        .unwrap();
1450        File::create(
1451            src.path()
1452                .join("src")
1453                .join("complex_namespace")
1454                .join("part_a")
1455                .join("__init__.py"),
1456        )
1457        .unwrap();
1458        fs_err::create_dir_all(
1459            src.path()
1460                .join("src")
1461                .join("complex_namespace")
1462                .join("part_b"),
1463        )
1464        .unwrap();
1465        File::create(
1466            src.path()
1467                .join("src")
1468                .join("complex_namespace")
1469                .join("part_b")
1470                .join("__init__.py"),
1471        )
1472        .unwrap();
1473
1474        let dist = TempDir::new().unwrap();
1475        let build1 = build(src.path(), dist.path(), Preview::default()).unwrap();
1476        assert_snapshot!(build1.wheel_contents.join("\n"), @"
1477        complex_namespace-1.0.0.dist-info/
1478        complex_namespace-1.0.0.dist-info/METADATA
1479        complex_namespace-1.0.0.dist-info/RECORD
1480        complex_namespace-1.0.0.dist-info/WHEEL
1481        complex_namespace/
1482        complex_namespace/part_a/
1483        complex_namespace/part_a/__init__.py
1484        complex_namespace/part_b/
1485        complex_namespace/part_b/__init__.py
1486        ");
1487
1488        // Check that setting the name manually works equally.
1489        let pyproject_toml = indoc! {r#"
1490            [project]
1491            name = "complex-namespace"
1492            version = "1.0.0"
1493
1494            [tool.uv.build-backend]
1495            module-name = "complex_namespace"
1496            namespace = true
1497
1498            [build-system]
1499            requires = ["uv_build>=0.5.15,<0.6.0"]
1500            build-backend = "uv_build"
1501            "#
1502        };
1503        fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap();
1504
1505        let build2 = build(src.path(), dist.path(), Preview::default()).unwrap();
1506        assert_eq!(build1, build2);
1507    }
1508
1509    /// Stubs for a namespace package.
1510    #[test]
1511    fn stubs_namespace() {
1512        let src = TempDir::new().unwrap();
1513        let pyproject_toml = indoc! {r#"
1514            [project]
1515            name = "cloud.db.schema-stubs"
1516            version = "1.0.0"
1517
1518            [tool.uv.build-backend]
1519            module-name = "cloud-stubs.db.schema"
1520
1521            [build-system]
1522            requires = ["uv_build>=0.5.15,<0.6.0"]
1523            build-backend = "uv_build"
1524            "#
1525        };
1526        fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap();
1527        fs_err::create_dir_all(
1528            src.path()
1529                .join("src")
1530                .join("cloud-stubs")
1531                .join("db")
1532                .join("schema"),
1533        )
1534        .unwrap();
1535        File::create(
1536            src.path()
1537                .join("src")
1538                .join("cloud-stubs")
1539                .join("db")
1540                .join("schema")
1541                .join("__init__.pyi"),
1542        )
1543        .unwrap();
1544
1545        let dist = TempDir::new().unwrap();
1546        let build = build(src.path(), dist.path(), Preview::default()).unwrap();
1547        assert_snapshot!(build.wheel_contents.join("\n"), @"
1548        cloud-stubs/
1549        cloud-stubs/db/
1550        cloud-stubs/db/schema/
1551        cloud-stubs/db/schema/__init__.pyi
1552        cloud_db_schema_stubs-1.0.0.dist-info/
1553        cloud_db_schema_stubs-1.0.0.dist-info/METADATA
1554        cloud_db_schema_stubs-1.0.0.dist-info/RECORD
1555        cloud_db_schema_stubs-1.0.0.dist-info/WHEEL
1556        ");
1557    }
1558
1559    /// A package with multiple modules, one a regular module and two namespace modules.
1560    #[test]
1561    fn multiple_module_names() {
1562        let src = TempDir::new().unwrap();
1563        let pyproject_toml = indoc! {r#"
1564            [project]
1565            name = "simple-namespace-part"
1566            version = "1.0.0"
1567
1568            [tool.uv.build-backend]
1569            module-name = ["foo", "simple_namespace.part_a", "simple_namespace.part_b"]
1570
1571            [build-system]
1572            requires = ["uv_build>=0.5.15,<0.6.0"]
1573            build-backend = "uv_build"
1574            "#
1575        };
1576        fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap();
1577        fs_err::create_dir_all(src.path().join("src").join("foo")).unwrap();
1578        fs_err::create_dir_all(
1579            src.path()
1580                .join("src")
1581                .join("simple_namespace")
1582                .join("part_a"),
1583        )
1584        .unwrap();
1585        fs_err::create_dir_all(
1586            src.path()
1587                .join("src")
1588                .join("simple_namespace")
1589                .join("part_b"),
1590        )
1591        .unwrap();
1592
1593        // Most of these checks exist in other tests too, but we want to ensure that they apply
1594        // with multiple modules too.
1595
1596        // The first module is missing an `__init__.py`.
1597        assert_snapshot!(
1598            build_err(src.path()),
1599            @"Expected a Python module at: [TEMP_PATH]/src/foo/__init__.py"
1600        );
1601
1602        // Create the first correct `__init__.py` file
1603        File::create(src.path().join("src").join("foo").join("__init__.py")).unwrap();
1604
1605        // The second module, a namespace, is missing an `__init__.py`.
1606        assert_snapshot!(
1607            build_err(src.path()),
1608            @"Expected a Python module at: [TEMP_PATH]/src/simple_namespace/part_a/__init__.py"
1609        );
1610
1611        // Create the other two correct `__init__.py` files
1612        File::create(
1613            src.path()
1614                .join("src")
1615                .join("simple_namespace")
1616                .join("part_a")
1617                .join("__init__.py"),
1618        )
1619        .unwrap();
1620        File::create(
1621            src.path()
1622                .join("src")
1623                .join("simple_namespace")
1624                .join("part_b")
1625                .join("__init__.py"),
1626        )
1627        .unwrap();
1628
1629        // For the second module, a namespace, there must not be an `__init__.py` here.
1630        let bogus_init_py = src
1631            .path()
1632            .join("src")
1633            .join("simple_namespace")
1634            .join("__init__.py");
1635        File::create(&bogus_init_py).unwrap();
1636        assert_snapshot!(
1637            build_err(src.path()),
1638            @"For namespace packages, `__init__.py[i]` is not allowed in parent directory: [TEMP_PATH]/src/simple_namespace"
1639        );
1640        fs_err::remove_file(bogus_init_py).unwrap();
1641
1642        let dist = TempDir::new().unwrap();
1643        let build = build(src.path(), dist.path(), Preview::default()).unwrap();
1644        assert_snapshot!(build.source_dist_contents.join("\n"), @"
1645        simple_namespace_part-1.0.0/
1646        simple_namespace_part-1.0.0/PKG-INFO
1647        simple_namespace_part-1.0.0/pyproject.toml
1648        simple_namespace_part-1.0.0/src
1649        simple_namespace_part-1.0.0/src/foo
1650        simple_namespace_part-1.0.0/src/foo/__init__.py
1651        simple_namespace_part-1.0.0/src/simple_namespace
1652        simple_namespace_part-1.0.0/src/simple_namespace/part_a
1653        simple_namespace_part-1.0.0/src/simple_namespace/part_a/__init__.py
1654        simple_namespace_part-1.0.0/src/simple_namespace/part_b
1655        simple_namespace_part-1.0.0/src/simple_namespace/part_b/__init__.py
1656        ");
1657        assert_snapshot!(build.wheel_contents.join("\n"), @"
1658        foo/
1659        foo/__init__.py
1660        simple_namespace/
1661        simple_namespace/part_a/
1662        simple_namespace/part_a/__init__.py
1663        simple_namespace/part_b/
1664        simple_namespace/part_b/__init__.py
1665        simple_namespace_part-1.0.0.dist-info/
1666        simple_namespace_part-1.0.0.dist-info/METADATA
1667        simple_namespace_part-1.0.0.dist-info/RECORD
1668        simple_namespace_part-1.0.0.dist-info/WHEEL
1669        ");
1670    }
1671
1672    /// `prune_redundant_modules` should remove modules which are already
1673    /// included (either directly or via their parent)
1674    #[test]
1675    fn test_prune_redundant_modules() {
1676        fn check(input: &[&str], expect: &[&str]) {
1677            let input = input.iter().map(|s| (*s).to_string()).collect();
1678            let expect: Vec<_> = expect.iter().map(|s| (*s).to_string()).collect();
1679            assert_eq!(prune_redundant_modules(input), expect);
1680        }
1681
1682        // Basic cases
1683        check(&[], &[]);
1684        check(&["foo"], &["foo"]);
1685        check(&["foo", "bar"], &["bar", "foo"]);
1686
1687        // Deshadowing
1688        check(&["foo", "foo.bar"], &["foo"]);
1689        check(&["foo.bar", "foo"], &["foo"]);
1690        check(
1691            &["foo.bar.a", "foo.bar.b", "foo.bar", "foo", "foo.bar.a.c"],
1692            &["foo"],
1693        );
1694        check(
1695            &["bar.one", "bar.two", "baz", "bar", "baz.one"],
1696            &["bar", "baz"],
1697        );
1698
1699        // Potential false positives
1700        check(&["foo", "foobar"], &["foo", "foobar"]);
1701        check(
1702            &["foo", "foobar", "foo.bar", "foobar.baz"],
1703            &["foo", "foobar"],
1704        );
1705        check(&["foo.bar", "foo.baz"], &["foo.bar", "foo.baz"]);
1706        check(&["foo", "foo", "foo.bar", "foo.bar"], &["foo"]);
1707
1708        // Everything
1709        check(
1710            &[
1711                "foo.inner",
1712                "foo.inner.deeper",
1713                "foo",
1714                "bar",
1715                "bar.sub",
1716                "bar.sub.deep",
1717                "foobar",
1718                "baz.baz.bar",
1719                "baz.baz",
1720                "qux",
1721            ],
1722            &["bar", "baz.baz", "foo", "foobar", "qux"],
1723        );
1724    }
1725
1726    /// A package with duplicate module names.
1727    #[test]
1728    fn duplicate_module_names() {
1729        let src = TempDir::new().unwrap();
1730        let pyproject_toml = indoc! {r#"
1731            [project]
1732            name = "duplicate"
1733            version = "1.0.0"
1734
1735            [tool.uv.build-backend]
1736            module-name = ["foo", "foo", "bar.baz", "bar.baz.submodule"]
1737
1738            [build-system]
1739            requires = ["uv_build>=0.5.15,<0.6.0"]
1740            build-backend = "uv_build"
1741            "#
1742        };
1743        fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap();
1744        fs_err::create_dir_all(src.path().join("src").join("foo")).unwrap();
1745        File::create(src.path().join("src").join("foo").join("__init__.py")).unwrap();
1746        fs_err::create_dir_all(src.path().join("src").join("bar").join("baz")).unwrap();
1747        File::create(
1748            src.path()
1749                .join("src")
1750                .join("bar")
1751                .join("baz")
1752                .join("__init__.py"),
1753        )
1754        .unwrap();
1755
1756        let dist = TempDir::new().unwrap();
1757        let build = build(src.path(), dist.path(), Preview::default()).unwrap();
1758        assert_snapshot!(build.source_dist_contents.join("\n"), @"
1759        duplicate-1.0.0/
1760        duplicate-1.0.0/PKG-INFO
1761        duplicate-1.0.0/pyproject.toml
1762        duplicate-1.0.0/src
1763        duplicate-1.0.0/src/bar
1764        duplicate-1.0.0/src/bar/baz
1765        duplicate-1.0.0/src/bar/baz/__init__.py
1766        duplicate-1.0.0/src/foo
1767        duplicate-1.0.0/src/foo/__init__.py
1768        ");
1769        assert_snapshot!(build.wheel_contents.join("\n"), @"
1770        bar/
1771        bar/baz/
1772        bar/baz/__init__.py
1773        duplicate-1.0.0.dist-info/
1774        duplicate-1.0.0.dist-info/METADATA
1775        duplicate-1.0.0.dist-info/RECORD
1776        duplicate-1.0.0.dist-info/WHEEL
1777        foo/
1778        foo/__init__.py
1779        ");
1780    }
1781
1782    /// Check that JSON metadata files are present.
1783    #[test]
1784    fn metadata_json_preview() {
1785        let src = TempDir::new().unwrap();
1786        fs_err::write(
1787            src.path().join("pyproject.toml"),
1788            indoc! {r#"
1789            [project]
1790            name = "metadata-json-preview"
1791            version = "1.0.0"
1792
1793            [build-system]
1794            requires = ["uv_build>=0.5.15,<0.6.0"]
1795            build-backend = "uv_build"
1796        "#
1797            },
1798        )
1799        .unwrap();
1800        fs_err::create_dir_all(src.path().join("src").join("metadata_json_preview")).unwrap();
1801        File::create(
1802            src.path()
1803                .join("src")
1804                .join("metadata_json_preview")
1805                .join("__init__.py"),
1806        )
1807        .unwrap();
1808
1809        let dist = TempDir::new().unwrap();
1810        let build = build(
1811            src.path(),
1812            dist.path(),
1813            Preview::new(&[PreviewFeature::MetadataJson]),
1814        )
1815        .unwrap();
1816
1817        assert_snapshot!(build.wheel_contents.join("\n"), @"
1818        metadata_json_preview-1.0.0.dist-info/
1819        metadata_json_preview-1.0.0.dist-info/METADATA
1820        metadata_json_preview-1.0.0.dist-info/METADATA.json
1821        metadata_json_preview-1.0.0.dist-info/RECORD
1822        metadata_json_preview-1.0.0.dist-info/WHEEL
1823        metadata_json_preview-1.0.0.dist-info/WHEEL.json
1824        metadata_json_preview/
1825        metadata_json_preview/__init__.py
1826        ");
1827    }
1828}