Build backend: Revamp include/exclude (#9525)

When building the source distribution, we always need to include
`pyproject.toml` and the module, when building the wheel, we always
include the module but nothing else at top level. Since we only allow a
single module per wheel, that means that there are no specific wheel
includes. This means we have source includes, source excludes, wheel
excludes, but no wheel includes: This is defined by the module root,
plus the metadata files and data directories separately.

Extra source dist includes are currently unused (they can't end up in
the wheel currently), but it makes sense to model them here, they will
be needed for any sort of procedural build step.

This results in the following fields being relevant for inclusions and
exclusion:

* `pyproject.toml` (always included in the source dist)
* project.readme: PEP 621
* project.license-files: PEP 639
* module_root: `Path`
* source_include: `Vec<Glob>`
* source_exclude: `Vec<Glob>`
* wheel_exclude: `Vec<Glob>`
* data: `Map<KnownDataName, Path>`

An opinionated choice is that that wheel excludes always contain the
source excludes: Otherwise you could have a path A in the source tree
that gets included when building the wheel directly from the source
tree, but not when going through the source dist as intermediary,
because A is in source excludes, but not in the wheel excludes. This has
been a source of errors previously.

In the process, I fixed a bug where we would skip directories and only
include the files and were missing license due to absolute globs.
This commit is contained in:
konsti 2024-12-01 12:32:35 +01:00 committed by GitHub
parent 8126a5ed32
commit dfcceb6a1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 272 additions and 209 deletions

View file

@ -1,6 +1,6 @@
mod metadata; mod metadata;
use crate::metadata::{PyProjectToml, ValidationError}; use crate::metadata::{BuildBackendSettings, PyProjectToml, ValidationError};
use flate2::write::GzEncoder; use flate2::write::GzEncoder;
use flate2::Compression; use flate2::Compression;
use fs_err::File; use fs_err::File;
@ -49,6 +49,8 @@ pub enum Error {
#[source] #[source]
err: globset::Error, err: globset::Error,
}, },
#[error("`pyproject.toml` must not be excluded from source distribution build")]
PyprojectTomlExcluded,
#[error("Failed to walk source tree: `{}`", root.user_display())] #[error("Failed to walk source tree: `{}`", root.user_display())]
WalkDir { WalkDir {
root: PathBuf, root: PathBuf,
@ -303,6 +305,10 @@ pub fn build_wheel(
let contents = fs_err::read_to_string(source_tree.join("pyproject.toml"))?; let contents = fs_err::read_to_string(source_tree.join("pyproject.toml"))?;
let pyproject_toml = PyProjectToml::parse(&contents)?; let pyproject_toml = PyProjectToml::parse(&contents)?;
pyproject_toml.check_build_system("1.0.0+test"); pyproject_toml.check_build_system("1.0.0+test");
let settings = pyproject_toml
.settings()
.cloned()
.unwrap_or_else(BuildBackendSettings::default);
check_metadata_directory(source_tree, metadata_directory, &pyproject_toml)?; check_metadata_directory(source_tree, metadata_directory, &pyproject_toml)?;
@ -320,28 +326,23 @@ pub fn build_wheel(
let mut wheel_writer = ZipDirectoryWriter::new_wheel(File::create(&wheel_path)?); let mut wheel_writer = ZipDirectoryWriter::new_wheel(File::create(&wheel_path)?);
// Wheel excludes // Wheel excludes
// TODO(konstin): The must be stronger than the source dist excludes, otherwise we can get more let mut excludes: Vec<String> = settings.wheel_exclude;
// files in source tree -> wheel than for source tree -> source dist -> wheel. // The wheel must not include any files excluded by the source distribution (at least until we
let default_excludes: &[String] = &[ // have files generated in the source dist -> wheel build step).
"__pycache__".to_string(), for exclude in settings.source_exclude {
"*.pyc".to_string(), // Avoid duplicate entries.
"*.pyo".to_string(), if !excludes.contains(&exclude) {
]; excludes.push(exclude);
let excludes = pyproject_toml }
.wheel_settings() }
.and_then(|settings| settings.exclude.as_deref()) debug!("Wheel excludes: {:?}", excludes);
.unwrap_or(default_excludes);
let exclude_matcher = build_exclude_matcher(excludes)?; let exclude_matcher = build_exclude_matcher(excludes)?;
debug!("Adding content files to {}", wheel_path.user_display()); debug!("Adding content files to {}", wheel_path.user_display());
let module_root = pyproject_toml if settings.module_root.is_absolute() {
.wheel_settings() return Err(Error::AbsoluteModuleRoot(settings.module_root.clone()));
.and_then(|wheel_settings| wheel_settings.module_root.as_deref())
.unwrap_or_else(|| Path::new("src"));
if module_root.is_absolute() {
return Err(Error::AbsoluteModuleRoot(module_root.to_path_buf()));
} }
let strip_root = source_tree.join(module_root); let strip_root = source_tree.join(settings.module_root);
let module_root = strip_root.join(pyproject_toml.name().as_dist_info_name().as_ref()); let module_root = strip_root.join(pyproject_toml.name().as_dist_info_name().as_ref());
if !module_root.join("__init__.py").is_file() { if !module_root.join("__init__.py").is_file() {
return Err(Error::MissingModule(module_root)); return Err(Error::MissingModule(module_root));
@ -364,21 +365,28 @@ pub fn build_wheel(
); );
} }
let relative_path = entry // We only want to take the module root, but since excludes start at the source tree root,
// we strip higher than we iterate.
let match_path = entry
.path()
.strip_prefix(source_tree)
.expect("walkdir starts with root");
let wheel_path = entry
.path() .path()
.strip_prefix(&strip_root) .strip_prefix(&strip_root)
.expect("walkdir starts with root"); .expect("walkdir starts with root");
if exclude_matcher.is_match(relative_path) { if exclude_matcher.is_match(match_path) {
trace!("Excluding from module: `{}`", relative_path.user_display()); trace!("Excluding from module: `{}`", match_path.user_display());
continue;
} }
let relative_path = relative_path.user_display().to_string(); let wheel_path = wheel_path.portable_display().to_string();
debug!("Adding to wheel: `{relative_path}`"); debug!("Adding to wheel: `{wheel_path}`");
if entry.file_type().is_dir() { if entry.file_type().is_dir() {
wheel_writer.write_directory(&relative_path)?; wheel_writer.write_directory(&wheel_path)?;
} else if entry.file_type().is_file() { } else if entry.file_type().is_file() {
wheel_writer.write_file(&relative_path, entry.path())?; wheel_writer.write_file(&wheel_path, entry.path())?;
} else { } else {
// TODO(konsti): We may want to support symlinks, there is support for installing them. // TODO(konsti): We may want to support symlinks, there is support for installing them.
return Err(Error::UnsupportedFileType( return Err(Error::UnsupportedFileType(
@ -408,12 +416,7 @@ pub fn build_wheel(
} }
// Add the data files // Add the data files
for (name, directory) in pyproject_toml for (name, directory) in settings.data.iter() {
.wheel_settings()
.and_then(|wheel_settings| wheel_settings.data.clone())
.unwrap_or_default()
.iter()
{
debug!("Adding {name} data files from: `{directory}`"); debug!("Adding {name} data files from: `{directory}`");
let data_dir = format!( let data_dir = format!(
"{}-{}.data/{}/", "{}-{}.data/{}/",
@ -427,7 +430,7 @@ pub fn build_wheel(
&data_dir, &data_dir,
&["**".to_string()], &["**".to_string()],
&mut wheel_writer, &mut wheel_writer,
&format!("tool.uv.wheel.data.{name}"), &format!("tool.uv.build-backend.data.{name}"),
)?; )?;
} }
@ -454,6 +457,10 @@ pub fn build_editable(
let contents = fs_err::read_to_string(source_tree.join("pyproject.toml"))?; let contents = fs_err::read_to_string(source_tree.join("pyproject.toml"))?;
let pyproject_toml = PyProjectToml::parse(&contents)?; let pyproject_toml = PyProjectToml::parse(&contents)?;
pyproject_toml.check_build_system("1.0.0+test"); pyproject_toml.check_build_system("1.0.0+test");
let settings = pyproject_toml
.settings()
.cloned()
.unwrap_or_else(BuildBackendSettings::default);
check_metadata_directory(source_tree, metadata_directory, &pyproject_toml)?; check_metadata_directory(source_tree, metadata_directory, &pyproject_toml)?;
@ -471,14 +478,10 @@ pub fn build_editable(
let mut wheel_writer = ZipDirectoryWriter::new_wheel(File::create(&wheel_path)?); let mut wheel_writer = ZipDirectoryWriter::new_wheel(File::create(&wheel_path)?);
debug!("Adding pth file to {}", wheel_path.user_display()); debug!("Adding pth file to {}", wheel_path.user_display());
let module_root = pyproject_toml if settings.module_root.is_absolute() {
.wheel_settings() return Err(Error::AbsoluteModuleRoot(settings.module_root.clone()));
.and_then(|wheel_settings| wheel_settings.module_root.as_deref())
.unwrap_or_else(|| Path::new("src"));
if module_root.is_absolute() {
return Err(Error::AbsoluteModuleRoot(module_root.to_path_buf()));
} }
let src_root = source_tree.join(module_root); let src_root = source_tree.join(settings.module_root);
let module_root = src_root.join(pyproject_toml.name().as_dist_info_name().as_ref()); let module_root = src_root.join(pyproject_toml.name().as_dist_info_name().as_ref());
if !module_root.join("__init__.py").is_file() { if !module_root.join("__init__.py").is_file() {
return Err(Error::MissingModule(module_root)); return Err(Error::MissingModule(module_root));
@ -581,57 +584,19 @@ fn wheel_subdir_from_globs(
Ok(()) Ok(())
} }
/// TODO(konsti): Wire this up with actual settings and remove this struct.
///
/// To select which files to include in the source distribution, we first add the includes, then
/// remove the excludes from that.
pub struct SourceDistSettings {
/// Glob expressions which files and directories to include in the source distribution.
///
/// Includes are anchored, which means that `pyproject.toml` includes only
/// `<project root>/pyproject.toml`. Use for example `assets/**/sample.csv` to include for all
/// `sample.csv` files in `<project root>/assets` or any child directory. To recursively include
/// all files under a directory, use a `/**` suffix, e.g. `src/**`. For performance and
/// reproducibility, avoid unanchored matches such as `**/sample.csv`.
///
/// The glob syntax is the reduced portable glob from
/// [PEP 639](https://peps.python.org/pep-0639/#add-license-FILES-key).
include: Vec<String>,
/// Glob expressions which files and directories to exclude from the previous source
/// distribution includes.
///
/// Excludes are not anchored, which means that `__pycache__` excludes all directories named
/// `__pycache__` and it's children anywhere. To anchor a directory, use a `/` prefix, e.g.,
/// `/dist` will exclude only `<project root>/dist`.
///
/// The glob syntax is the reduced portable glob from
/// [PEP 639](https://peps.python.org/pep-0639/#add-license-FILES-key).
exclude: Vec<String>,
}
impl Default for SourceDistSettings {
fn default() -> Self {
Self {
include: vec!["src/**".to_string(), "pyproject.toml".to_string()],
exclude: vec![
"__pycache__".to_string(),
"*.pyc".to_string(),
"*.pyo".to_string(),
],
}
}
}
/// Build a source distribution from the source tree and place it in the output directory. /// Build a source distribution from the source tree and place it in the output directory.
pub fn build_source_dist( pub fn build_source_dist(
source_tree: &Path, source_tree: &Path,
source_dist_directory: &Path, source_dist_directory: &Path,
settings: SourceDistSettings,
uv_version: &str, uv_version: &str,
) -> Result<SourceDistFilename, Error> { ) -> Result<SourceDistFilename, Error> {
let contents = fs_err::read_to_string(source_tree.join("pyproject.toml"))?; let contents = fs_err::read_to_string(source_tree.join("pyproject.toml"))?;
let pyproject_toml = PyProjectToml::parse(&contents)?; let pyproject_toml = PyProjectToml::parse(&contents)?;
pyproject_toml.check_build_system(uv_version); pyproject_toml.check_build_system(uv_version);
let settings = pyproject_toml
.settings()
.cloned()
.unwrap_or_else(BuildBackendSettings::default);
let filename = SourceDistFilename { let filename = SourceDistFilename {
name: pyproject_toml.name().clone(), name: pyproject_toml.name().clone(),
@ -664,11 +629,22 @@ pub fn build_source_dist(
) )
.map_err(|err| Error::TarWrite(source_dist_path.clone(), err))?; .map_err(|err| Error::TarWrite(source_dist_path.clone(), err))?;
// The user (or default) includes // File and directories to include in the source directory
let mut include_globs = Vec::new(); let mut include_globs = Vec::new();
for include in settings.include { let mut includes: Vec<String> = settings.source_include;
// pyproject.toml is always included.
includes.push(globset::escape("pyproject.toml"));
// The wheel must not include any files included by the source distribution (at least until we
// have files generated in the source dist -> wheel build step).
let import_path = &settings
.module_root
.join(pyproject_toml.name().as_dist_info_name().as_ref())
.portable_display()
.to_string();
includes.push(format!("{}/**", globset::escape(import_path)));
for include in includes {
let glob = parse_portable_glob(&include).map_err(|err| Error::PortableGlob { let glob = parse_portable_glob(&include).map_err(|err| Error::PortableGlob {
field: "tool.uv.source-dist.include".to_string(), field: "tool.uv.build-backend.source-include".to_string(),
source: err, source: err,
})?; })?;
include_globs.push(glob.clone()); include_globs.push(glob.clone());
@ -698,16 +674,11 @@ pub fn build_source_dist(
} }
// Include the data files // Include the data files
for (name, directory) in pyproject_toml for (name, directory) in settings.data.iter() {
.wheel_settings()
.and_then(|wheel_settings| wheel_settings.data.clone())
.unwrap_or_default()
.iter()
{
let glob = let glob =
parse_portable_glob(&format!("{}/**", globset::escape(directory))).map_err(|err| { parse_portable_glob(&format!("{}/**", globset::escape(directory))).map_err(|err| {
Error::PortableGlob { Error::PortableGlob {
field: format!("tool.uv.wheel.data.{name}"), field: format!("tool.uv.build-backend.data.{name}"),
source: err, source: err,
} }
})?; })?;
@ -717,11 +688,17 @@ pub fn build_source_dist(
let include_matcher = let include_matcher =
GlobDirFilter::from_globs(&include_globs).map_err(|err| Error::GlobSetTooLarge { GlobDirFilter::from_globs(&include_globs).map_err(|err| Error::GlobSetTooLarge {
field: "tool.uv.source-dist.include".to_string(), field: "tool.uv.build-backend.source-include".to_string(),
source: err, source: err,
})?; })?;
let exclude_matcher = build_exclude_matcher(&settings.exclude)?; let mut excludes: Vec<String> = Vec::new();
excludes.extend(settings.source_exclude);
debug!("Source dist excludes: {:?}", excludes);
let exclude_matcher = build_exclude_matcher(excludes)?;
if exclude_matcher.is_match("pyproject.toml") {
return Err(Error::PyprojectTomlExcluded);
}
let mut files_visited = 0; let mut files_visited = 0;
for entry in WalkDir::new(source_tree).into_iter().filter_entry(|entry| { for entry in WalkDir::new(source_tree).into_iter().filter_entry(|entry| {
@ -773,9 +750,12 @@ pub fn build_source_dist(
} }
/// Build a globset matcher for excludes. /// Build a globset matcher for excludes.
fn build_exclude_matcher(excludes: &[String]) -> Result<GlobSet, Error> { fn build_exclude_matcher(
excludes: impl IntoIterator<Item = impl AsRef<str>>,
) -> Result<GlobSet, Error> {
let mut exclude_builder = GlobSetBuilder::new(); let mut exclude_builder = GlobSetBuilder::new();
for exclude in excludes { for exclude in excludes {
let exclude = exclude.as_ref();
// Excludes are unanchored // Excludes are unanchored
let exclude = if let Some(exclude) = exclude.strip_prefix("/") { let exclude = if let Some(exclude) = exclude.strip_prefix("/") {
exclude.to_string() exclude.to_string()
@ -783,7 +763,7 @@ fn build_exclude_matcher(excludes: &[String]) -> Result<GlobSet, Error> {
format!("**/{exclude}").to_string() format!("**/{exclude}").to_string()
}; };
let glob = parse_portable_glob(&exclude).map_err(|err| Error::PortableGlob { let glob = parse_portable_glob(&exclude).map_err(|err| Error::PortableGlob {
field: "tool.uv.source-dist.exclude".to_string(), field: "tool.uv.build-backend.*-exclude".to_string(),
source: err, source: err,
})?; })?;
exclude_builder.add(glob); exclude_builder.add(glob);
@ -791,7 +771,7 @@ fn build_exclude_matcher(excludes: &[String]) -> Result<GlobSet, Error> {
let exclude_matcher = exclude_builder let exclude_matcher = exclude_builder
.build() .build()
.map_err(|err| Error::GlobSetTooLarge { .map_err(|err| Error::GlobSetTooLarge {
field: "tool.uv.source-dist.exclude".to_string(), field: "tool.uv.build-backend.*-exclude".to_string(),
source: err, source: err,
})?; })?;
Ok(exclude_matcher) Ok(exclude_matcher)
@ -1096,27 +1076,30 @@ mod tests {
.path() .path()
.join("built_by_uv-0.1.0.dist-info/METADATA"); .join("built_by_uv-0.1.0.dist-info/METADATA");
assert_snapshot!(fs_err::read_to_string(metadata_file).unwrap(), @r###" assert_snapshot!(fs_err::read_to_string(metadata_file).unwrap(), @r###"
Metadata-Version: 2.4 Metadata-Version: 2.4
Name: built-by-uv Name: built-by-uv
Version: 0.1.0 Version: 0.1.0
Summary: A package to be built with the uv build backend that uses all features exposed by the build backend Summary: A package to be built with the uv build backend that uses all features exposed by the build backend
Requires-Dist: anyio>=4,<5 License-File: LICENSE-APACHE
Requires-Python: >=3.12 License-File: LICENSE-MIT
Description-Content-Type: text/markdown License-File: third-party-licenses/PEP-401.txt
Requires-Dist: anyio>=4,<5
Requires-Python: >=3.12
Description-Content-Type: text/markdown
# built_by_uv # built_by_uv
A package to be built with the uv build backend that uses all features exposed by the build backend. A package to be built with the uv build backend that uses all features exposed by the build backend.
"###); "###);
let record_file = metadata_dir let record_file = metadata_dir
.path() .path()
.join("built_by_uv-0.1.0.dist-info/RECORD"); .join("built_by_uv-0.1.0.dist-info/RECORD");
assert_snapshot!(fs_err::read_to_string(record_file).unwrap(), @r###" assert_snapshot!(fs_err::read_to_string(record_file).unwrap(), @r###"
built_by_uv-0.1.0.dist-info/WHEEL,sha256=3da1bfa0e8fd1b6cc246aa0b2b44a35815596c600cb485c39a6f8c106c3d5a8d,83 built_by_uv-0.1.0.dist-info/WHEEL,sha256=3da1bfa0e8fd1b6cc246aa0b2b44a35815596c600cb485c39a6f8c106c3d5a8d,83
built_by_uv-0.1.0.dist-info/METADATA,sha256=acb91f5a18cb53fa57b45eb4590ea13195a774c856a9dd8cf27cc5435d6451b6,372 built_by_uv-0.1.0.dist-info/METADATA,sha256=9ba12456f2ab1a6ab1e376ff551e392c70f7ec86713d80b4348e90c7dfd45cb1,474
built_by_uv-0.1.0.dist-info/RECORD,, built_by_uv-0.1.0.dist-info/RECORD,,
"###); "###);
let wheel_file = metadata_dir let wheel_file = metadata_dir
.path() .path()
@ -1180,13 +1163,7 @@ mod tests {
// Build a source dist from the source tree // Build a source dist from the source tree
let source_dist_dir = TempDir::new().unwrap(); let source_dist_dir = TempDir::new().unwrap();
build_source_dist( build_source_dist(src.path(), source_dist_dir.path(), "1.0.0+test").unwrap();
src.path(),
source_dist_dir.path(),
SourceDistSettings::default(),
"1.0.0+test",
)
.unwrap();
// Build a wheel from the source dist // Build a wheel from the source dist
let sdist_tree = TempDir::new().unwrap(); let sdist_tree = TempDir::new().unwrap();
@ -1213,26 +1190,6 @@ mod tests {
"1.0.0+test", "1.0.0+test",
) )
.unwrap(); .unwrap();
// Check the contained files and directories
assert_snapshot!(source_dist_contents.iter().map(|path| path.replace('\\', "/")).join("\n"), @r"
built_by_uv-0.1.0/LICENSE-APACHE
built_by_uv-0.1.0/LICENSE-MIT
built_by_uv-0.1.0/PKG-INFO
built_by_uv-0.1.0/README.md
built_by_uv-0.1.0/assets/data.csv
built_by_uv-0.1.0/header/built_by_uv.h
built_by_uv-0.1.0/pyproject.toml
built_by_uv-0.1.0/scripts/whoami.sh
built_by_uv-0.1.0/src/built_by_uv
built_by_uv-0.1.0/src/built_by_uv/__init__.py
built_by_uv-0.1.0/src/built_by_uv/arithmetic
built_by_uv-0.1.0/src/built_by_uv/arithmetic/__init__.py
built_by_uv-0.1.0/src/built_by_uv/arithmetic/circle.py
built_by_uv-0.1.0/src/built_by_uv/arithmetic/pi.txt
built_by_uv-0.1.0/third-party-licenses/PEP-401.txt
");
let wheel = zip::ZipArchive::new( let wheel = zip::ZipArchive::new(
File::open( File::open(
indirect_output_dir indirect_output_dir
@ -1246,28 +1203,55 @@ mod tests {
indirect_wheel_contents.sort_unstable(); indirect_wheel_contents.sort_unstable();
assert_eq!(indirect_wheel_contents, direct_wheel_contents); assert_eq!(indirect_wheel_contents, direct_wheel_contents);
assert_snapshot!(indirect_wheel_contents.iter().map(|path| path.replace('\\', "/")).join("\n"), @r" // Check the contained files and directories
built_by_uv-0.1.0.data/data/ assert_snapshot!(source_dist_contents.iter().map(|path| path.replace('\\', "/")).join("\n"), @r###"
built_by_uv-0.1.0.data/data/data.csv built_by_uv-0.1.0/
built_by_uv-0.1.0.data/headers/ built_by_uv-0.1.0/LICENSE-APACHE
built_by_uv-0.1.0.data/headers/built_by_uv.h built_by_uv-0.1.0/LICENSE-MIT
built_by_uv-0.1.0.data/scripts/ built_by_uv-0.1.0/PKG-INFO
built_by_uv-0.1.0.data/scripts/whoami.sh built_by_uv-0.1.0/README.md
built_by_uv-0.1.0.dist-info/ built_by_uv-0.1.0/assets
built_by_uv-0.1.0.dist-info/METADATA built_by_uv-0.1.0/assets/data.csv
built_by_uv-0.1.0.dist-info/RECORD built_by_uv-0.1.0/header
built_by_uv-0.1.0.dist-info/WHEEL built_by_uv-0.1.0/header/built_by_uv.h
built_by_uv-0.1.0.dist-info/licenses/ built_by_uv-0.1.0/pyproject.toml
built_by_uv-0.1.0.dist-info/licenses/LICENSE-APACHE built_by_uv-0.1.0/scripts
built_by_uv-0.1.0.dist-info/licenses/LICENSE-MIT built_by_uv-0.1.0/scripts/whoami.sh
built_by_uv-0.1.0.dist-info/licenses/third-party-licenses/PEP-401.txt built_by_uv-0.1.0/src
built_by_uv/ built_by_uv-0.1.0/src/built_by_uv
built_by_uv/__init__.py built_by_uv-0.1.0/src/built_by_uv/__init__.py
built_by_uv/arithmetic/ built_by_uv-0.1.0/src/built_by_uv/arithmetic
built_by_uv/arithmetic/__init__.py built_by_uv-0.1.0/src/built_by_uv/arithmetic/__init__.py
built_by_uv/arithmetic/circle.py built_by_uv-0.1.0/src/built_by_uv/arithmetic/circle.py
built_by_uv/arithmetic/pi.txt built_by_uv-0.1.0/src/built_by_uv/arithmetic/pi.txt
"); built_by_uv-0.1.0/src/built_by_uv/build-only.h
built_by_uv-0.1.0/third-party-licenses
built_by_uv-0.1.0/third-party-licenses/PEP-401.txt
"###);
assert_snapshot!(indirect_wheel_contents.iter().map(|path| path.replace('\\', "/")).join("\n"), @r###"
built_by_uv-0.1.0.data/data/
built_by_uv-0.1.0.data/data/data.csv
built_by_uv-0.1.0.data/headers/
built_by_uv-0.1.0.data/headers/built_by_uv.h
built_by_uv-0.1.0.data/scripts/
built_by_uv-0.1.0.data/scripts/whoami.sh
built_by_uv-0.1.0.dist-info/
built_by_uv-0.1.0.dist-info/METADATA
built_by_uv-0.1.0.dist-info/RECORD
built_by_uv-0.1.0.dist-info/WHEEL
built_by_uv-0.1.0.dist-info/licenses/
built_by_uv-0.1.0.dist-info/licenses/LICENSE-APACHE
built_by_uv-0.1.0.dist-info/licenses/LICENSE-MIT
built_by_uv-0.1.0.dist-info/licenses/third-party-licenses/
built_by_uv-0.1.0.dist-info/licenses/third-party-licenses/PEP-401.txt
built_by_uv/
built_by_uv/__init__.py
built_by_uv/arithmetic/
built_by_uv/arithmetic/__init__.py
built_by_uv/arithmetic/circle.py
built_by_uv/arithmetic/pi.txt
"###);
// Check that we write deterministic wheels. // Check that we write deterministic wheels.
let wheel_filename = "built_by_uv-0.1.0-py3-none-any.whl"; let wheel_filename = "built_by_uv-0.1.0-py3-none-any.whl";

View file

@ -1,5 +1,4 @@
use crate::Error; use crate::Error;
use globset::Glob;
use itertools::Itertools; use itertools::Itertools;
use serde::Deserialize; use serde::Deserialize;
use std::collections::{BTreeMap, Bound}; use std::collections::{BTreeMap, Bound};
@ -17,6 +16,9 @@ use uv_warnings::warn_user_once;
use version_ranges::Ranges; use version_ranges::Ranges;
use walkdir::WalkDir; use walkdir::WalkDir;
/// By default, we ignore generated python files.
const DEFAULT_EXCLUDES: &[&str] = &["__pycache__", "*.pyc", "*.pyo"];
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ValidationError { pub enum ValidationError {
/// The spec isn't clear about what the values in that field would be, and we only support the /// The spec isn't clear about what the values in that field would be, and we only support the
@ -86,8 +88,8 @@ impl PyProjectToml {
self.project.license_files.as_deref() self.project.license_files.as_deref()
} }
pub(crate) fn wheel_settings(&self) -> Option<&WheelSettings> { pub(crate) fn settings(&self) -> Option<&BuildBackendSettings> {
self.tool.as_ref()?.uv.as_ref()?.wheel.as_ref() self.tool.as_ref()?.uv.as_ref()?.build_backend.as_ref()
} }
/// Warn if the `[build-system]` table looks suspicious. /// Warn if the `[build-system]` table looks suspicious.
@ -335,23 +337,12 @@ impl PyProjectToml {
field: license_glob.to_string(), field: license_glob.to_string(),
source: err, source: err,
})?; })?;
let absolute_glob = PathBuf::from(globset::escape( license_globs_parsed.push(pep639_glob);
root.simplified().to_string_lossy().as_ref(),
))
.join(pep639_glob.to_string())
.to_string_lossy()
.to_string();
license_globs_parsed.push(Glob::new(&absolute_glob).map_err(|err| {
Error::GlobSet {
field: "project.license-files".to_string(),
err,
}
})?);
} }
let license_globs = let license_globs =
GlobDirFilter::from_globs(&license_globs_parsed).map_err(|err| { GlobDirFilter::from_globs(&license_globs_parsed).map_err(|err| {
Error::GlobSetTooLarge { Error::GlobSetTooLarge {
field: "tool.uv.source-dist.include".to_string(), field: "tool.uv.build-backend.source-include".to_string(),
source: err, source: err,
} }
})?; })?;
@ -365,7 +356,7 @@ impl PyProjectToml {
) )
}) { }) {
let entry = entry.map_err(|err| Error::WalkDir { let entry = entry.map_err(|err| Error::WalkDir {
root: PathBuf::from("."), root: root.to_path_buf(),
err, err,
})?; })?;
let relative = entry let relative = entry
@ -376,13 +367,16 @@ impl PyProjectToml {
trace!("Not a license files match: `{}`", relative.user_display()); trace!("Not a license files match: `{}`", relative.user_display());
continue; continue;
} }
if !entry.file_type().is_file() {
trace!(
"Not a file in license files match: `{}`",
relative.user_display()
);
continue;
}
debug!("License files match: `{}`", relative.user_display()); debug!("License files match: `{}`", relative.user_display());
let license_file = relative.to_string_lossy().to_string(); license_files.push(relative.portable_display().to_string());
if !license_files.contains(&license_file) {
license_files.push(license_file);
}
} }
// The glob order may be unstable // The glob order may be unstable
@ -707,23 +701,62 @@ pub(crate) struct Tool {
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub(crate) struct ToolUv { pub(crate) struct ToolUv {
/// Configuration for building source dists with the uv build backend /// Configuration for building source distributions and wheels with the uv build backend
#[allow(dead_code)] build_backend: Option<BuildBackendSettings>,
source_dist: Option<serde::de::IgnoredAny>,
/// Configuration for building wheels with the uv build backend
wheel: Option<WheelSettings>,
} }
/// The `tool.uv.wheel` section with wheel build configuration. /// To select which files to include in the source distribution, we first add the includes, then
/// remove the excludes from that.
///
/// When building the source distribution, the following files and directories are included:
/// * `pyproject.toml`
/// * The module under `tool.uv.build-backend.module-root`, by default
/// `src/<project_name_with_underscores>/**`.
/// * `project.license-files` and `project.readme`.
/// * All directories under `tool.uv.build-backend.data`.
/// * All patterns from `tool.uv.build-backend.source-include`.
///
/// From these, we remove the `tool.uv.build-backend.source-exclude` matches.
///
/// When building the wheel, the following files and directories are included:
/// * The module under `tool.uv.build-backend.module-root`, by default
/// `src/<project_name_with_underscores>/**`.
/// * `project.license-files` and `project.readme`, as part of the project metadata.
/// * Each directory under `tool.uv.build-backend.data`, as data directories.
///
/// From these, we remove the `tool.uv.build-backend.source-exclude` and
/// `tool.uv.build-backend.wheel-exclude` matches. The source dist excludes are applied to avoid
/// source tree -> wheel source including more files than
/// source tree -> source distribution -> wheel.
///
/// There are no specific wheel includes. There must only be one top level module, and all data
/// files must either be under the module root or in a data directory. Most packages store small
/// data in the module root alongside the source code.
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "kebab-case")] #[serde(default, rename_all = "kebab-case")]
pub(crate) struct WheelSettings { pub(crate) struct BuildBackendSettings {
/// The directory that contains the module directory, usually `src`, or an empty path when /// The directory that contains the module directory, usually `src`, or an empty path when
/// using the flat layout over the src layout. /// using the flat layout over the src layout.
pub(crate) module_root: Option<PathBuf>, pub(crate) module_root: PathBuf,
/// Glob expressions which files and directories to exclude from the previous source /// Glob expressions which files and directories to additionally include in the source
/// distribution includes. /// distribution.
///
/// `pyproject.toml` and the contents of the module directory are always included.
///
/// Includes are anchored, which means that `pyproject.toml` includes only
/// `<project root>/pyproject.toml`. Use for example `assets/**/sample.csv` to include for all
/// `sample.csv` files in `<project root>/assets` or any child directory. To recursively include
/// all files under a directory, use a `/**` suffix, e.g. `src/**`. For performance and
/// reproducibility, avoid unanchored matches such as `**/sample.csv`.
///
/// The glob syntax is the reduced portable glob from
/// [PEP 639](https://peps.python.org/pep-0639/#add-license-FILES-key).
pub(crate) source_include: Vec<String>,
/// Glob expressions which files and directories to exclude from the source distribution.
///
/// Default: `__pycache__`, `*.pyc`, and `*.pyo`.
/// ///
/// Excludes are not anchored, which means that `__pycache__` excludes all directories named /// Excludes are not anchored, which means that `__pycache__` excludes all directories named
/// `__pycache__` and it's children anywhere. To anchor a directory, use a `/` prefix, e.g., /// `__pycache__` and it's children anywhere. To anchor a directory, use a `/` prefix, e.g.,
@ -731,9 +764,37 @@ pub(crate) struct WheelSettings {
/// ///
/// The glob syntax is the reduced portable glob from /// The glob syntax is the reduced portable glob from
/// [PEP 639](https://peps.python.org/pep-0639/#add-license-FILES-key). /// [PEP 639](https://peps.python.org/pep-0639/#add-license-FILES-key).
pub(crate) exclude: Option<Vec<String>>, pub(crate) source_exclude: Vec<String>,
/// Glob expressions which files and directories to exclude from the wheel.
///
/// Default: `__pycache__`, `*.pyc`, and `*.pyo`.
///
/// Excludes are not anchored, which means that `__pycache__` excludes all directories named
/// `__pycache__` and it's children anywhere. To anchor a directory, use a `/` prefix, e.g.,
/// `/dist` will exclude only `<project root>/dist`.
///
/// The glob syntax is the reduced portable glob from
/// [PEP 639](https://peps.python.org/pep-0639/#add-license-FILES-key).
pub(crate) wheel_exclude: Vec<String>,
/// Data includes for wheels. /// Data includes for wheels.
pub(crate) data: Option<WheelDataIncludes>, ///
/// The directories included here are also included in the source distribution. They are copied
/// to the right wheel subdirectory on build.
pub(crate) data: WheelDataIncludes,
}
impl Default for BuildBackendSettings {
fn default() -> Self {
Self {
module_root: PathBuf::from("src"),
source_include: Vec::new(),
source_exclude: DEFAULT_EXCLUDES.iter().map(ToString::to_string).collect(),
wheel_exclude: DEFAULT_EXCLUDES.iter().map(ToString::to_string).collect(),
data: WheelDataIncludes::default(),
}
}
} }
/// Data includes for wheels. /// Data includes for wheels.
@ -754,7 +815,7 @@ pub(crate) struct WheelSettings {
/// uses these two options. /// uses these two options.
#[derive(Default, Deserialize, Debug, Clone)] #[derive(Default, Deserialize, Debug, Clone)]
// `deny_unknown_fields` to catch typos such as `header` vs `headers`. // `deny_unknown_fields` to catch typos such as `header` vs `headers`.
#[serde(rename_all = "kebab-case", deny_unknown_fields)] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
pub(crate) struct WheelDataIncludes { pub(crate) struct WheelDataIncludes {
purelib: Option<String>, purelib: Option<String>,
platlib: Option<String>, platlib: Option<String>,

View file

@ -74,8 +74,10 @@ impl GlobDirFilter {
} }
/// Whether the path (file or directory) matches any of the globs. /// Whether the path (file or directory) matches any of the globs.
///
/// We include a directory if we are potentially including files it contains.
pub fn match_path(&self, path: &Path) -> bool { pub fn match_path(&self, path: &Path) -> bool {
self.glob_set.is_match(path) self.match_directory(path) || self.glob_set.is_match(path)
} }
/// Check whether a directory or any of its children can be matched by any of the globs. /// Check whether a directory or any of its children can be matched by any of the globs.
@ -261,9 +263,16 @@ mod tests {
assert_eq!( assert_eq!(
matches, matches,
[ [
"",
"path1",
"path1/dir1", "path1/dir1",
"path2",
"path2/dir2", "path2/dir2",
"path3",
"path3/dir3",
"path3/dir3/subdir",
"path3/dir3/subdir/a.txt", "path3/dir3/subdir/a.txt",
"path4",
"path4/dir4", "path4/dir4",
"path4/dir4/subdir", "path4/dir4/subdir",
"path4/dir4/subdir/a.txt", "path4/dir4/subdir/a.txt",

View file

@ -18,7 +18,7 @@ pub enum PortableGlobError {
pos: usize, pos: usize,
invalid: char, invalid: char,
}, },
#[error("Invalid character `{invalid}` at position {pos} in glob: `{glob}`")] #[error("Invalid character `{invalid}` in range at position {pos} in glob: `{glob}`")]
InvalidCharacterRange { InvalidCharacterRange {
glob: String, glob: String,
pos: usize, pos: usize,
@ -145,11 +145,11 @@ mod tests {
); );
assert_snapshot!( assert_snapshot!(
parse_err("licenses/LICEN[!C]E.txt"), parse_err("licenses/LICEN[!C]E.txt"),
@"Invalid character `!` at position 15 in glob: `licenses/LICEN[!C]E.txt`" @"Invalid character `!` in range at position 15 in glob: `licenses/LICEN[!C]E.txt`"
); );
assert_snapshot!( assert_snapshot!(
parse_err("licenses/LICEN[C?]E.txt"), parse_err("licenses/LICEN[C?]E.txt"),
@"Invalid character `?` at position 16 in glob: `licenses/LICEN[C?]E.txt`" @"Invalid character `?` in range at position 16 in glob: `licenses/LICEN[C?]E.txt`"
); );
assert_snapshot!( assert_snapshot!(
parse_err("******"), parse_err("******"),

View file

@ -1646,9 +1646,7 @@ pub struct OptionsWire {
// Build backend // Build backend
#[allow(dead_code)] #[allow(dead_code)]
source_dist: Option<serde::de::IgnoredAny>, build_backend: Option<serde::de::IgnoredAny>,
#[allow(dead_code)]
wheel: Option<serde::de::IgnoredAny>,
} }
impl From<OptionsWire> for Options { impl From<OptionsWire> for Options {
@ -1707,8 +1705,7 @@ impl From<OptionsWire> for Options {
managed, managed,
package, package,
// Used by the build backend // Used by the build backend
source_dist: _, build_backend: _,
wheel: _,
} = value; } = value;
Self { Self {

View file

@ -5,14 +5,12 @@ use anyhow::{Context, Result};
use std::env; use std::env;
use std::io::Write; use std::io::Write;
use std::path::Path; use std::path::Path;
use uv_build_backend::SourceDistSettings;
/// PEP 517 hook to build a source distribution. /// PEP 517 hook to build a source distribution.
pub(crate) fn build_sdist(sdist_directory: &Path) -> Result<ExitStatus> { pub(crate) fn build_sdist(sdist_directory: &Path) -> Result<ExitStatus> {
let filename = uv_build_backend::build_source_dist( let filename = uv_build_backend::build_source_dist(
&env::current_dir()?, &env::current_dir()?,
sdist_directory, sdist_directory,
SourceDistSettings::default(),
uv_version::version(), uv_version::version(),
)?; )?;
// Tell the build frontend about the name of the artifact we built // Tell the build frontend about the name of the artifact we built

View file

@ -3443,7 +3443,7 @@ fn resolve_config_file() -> anyhow::Result<()> {
| |
1 | [project] 1 | [project]
| ^^^^^^^ | ^^^^^^^
unknown field `project`, expected one of `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `dependency-metadata`, `config-settings`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `python-install-mirror`, `pypy-install-mirror`, `publish-url`, `trusted-publishing`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `environments`, `conflicts`, `workspace`, `sources`, `managed`, `package`, `default-groups`, `dev-dependencies`, `source-dist`, `wheel` unknown field `project`, expected one of `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `dependency-metadata`, `config-settings`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `python-install-mirror`, `pypy-install-mirror`, `publish-url`, `trusted-publishing`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `environments`, `conflicts`, `workspace`, `sources`, `managed`, `package`, `default-groups`, `dev-dependencies`, `build-backend`
"### "###
); );

View file

@ -0,0 +1 @@
print("Build script (currently unused)")

View file

@ -7,7 +7,15 @@ requires-python = ">=3.12"
dependencies = ["anyio>=4,<5"] dependencies = ["anyio>=4,<5"]
license-files = ["LICENSE*", "third-party-licenses/*"] license-files = ["LICENSE*", "third-party-licenses/*"]
[tool.uv.wheel.data] [tool.uv.build-backend]
# A file we need for the source dist -> wheel step, but not in the wheel itself (currently unused)
source-include = ["data/build-script.py"]
# A temporary or generated file we want to ignore
source-exclude = ["/src/built_by_uv/not-packaged.txt", "__pycache__", "*.pyc", "*.pyo"]
# Headers are build-only
wheel-exclude = ["build-*.h"]
[tool.uv.build-backend.data]
scripts = "scripts" scripts = "scripts"
data = "assets" data = "assets"
headers = "header" headers = "header"

View file

@ -0,0 +1,4 @@
// There is no build step yet, but we're already modelling the basis for it by allowing files only in the source dist,
// but not in the wheel.
#include <pybind11/pybind11.h>

View file

@ -0,0 +1 @@
This file should only exist locally.