mirror of https://github.com/astral-sh/uv
Error when a `project.license-files` glob matches nothing (#16697)
Resolves https://github.com/astral-sh/uv/issues/16693 [`PEP 639`](https://peps.python.org/pep-0639/#add-license-files-key) requires build tools to error if any user-specified `project.license-files` glob fails to match a file, but uv currently allows the build to succeed and produces empty `.dist-info/licenses/` directories. This PR enforces the spec by tracking matches for each glob during metadata generation, raising a clear validation error when one is unmatched.
This commit is contained in:
parent
f5ce5b47c8
commit
1a14d595fd
|
|
@ -60,6 +60,8 @@ pub enum ValidationError {
|
||||||
ReservedGuiScripts,
|
ReservedGuiScripts,
|
||||||
#[error("`project.license` is not a valid SPDX expression: {0}")]
|
#[error("`project.license` is not a valid SPDX expression: {0}")]
|
||||||
InvalidSpdx(String, #[source] spdx::error::ParseError),
|
InvalidSpdx(String, #[source] spdx::error::ParseError),
|
||||||
|
#[error("`{field}` glob `{glob}` did not match any files")]
|
||||||
|
LicenseGlobNoMatches { field: String, glob: String },
|
||||||
#[error("License file `{}` must be UTF-8 encoded", _0)]
|
#[error("License file `{}` must be UTF-8 encoded", _0)]
|
||||||
LicenseFileNotUtf8(String),
|
LicenseFileNotUtf8(String),
|
||||||
}
|
}
|
||||||
|
|
@ -447,7 +449,9 @@ impl PyProjectToml {
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut license_files = Vec::new();
|
let mut license_files = Vec::new();
|
||||||
let mut license_globs_parsed = Vec::new();
|
let mut license_globs_parsed = Vec::with_capacity(license_globs.len());
|
||||||
|
let mut license_glob_matchers = Vec::with_capacity(license_globs.len());
|
||||||
|
|
||||||
for license_glob in license_globs {
|
for license_glob in license_globs {
|
||||||
let pep639_glob =
|
let pep639_glob =
|
||||||
PortableGlobParser::Pep639
|
PortableGlobParser::Pep639
|
||||||
|
|
@ -456,12 +460,17 @@ impl PyProjectToml {
|
||||||
field: license_glob.to_owned(),
|
field: license_glob.to_owned(),
|
||||||
source: err,
|
source: err,
|
||||||
})?;
|
})?;
|
||||||
|
license_glob_matchers.push(pep639_glob.compile_matcher());
|
||||||
license_globs_parsed.push(pep639_glob);
|
license_globs_parsed.push(pep639_glob);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track whether each user-specified glob matched so we can flag the unmatched ones.
|
||||||
|
let mut license_globs_matched = vec![false; license_globs_parsed.len()];
|
||||||
|
|
||||||
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.build-backend.source-include".to_string(),
|
field: "project.license-files".to_string(),
|
||||||
source: err,
|
source: err,
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
@ -482,17 +491,22 @@ impl PyProjectToml {
|
||||||
root: root.to_path_buf(),
|
root: root.to_path_buf(),
|
||||||
err,
|
err,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let relative = entry
|
let relative = entry
|
||||||
.path()
|
.path()
|
||||||
.strip_prefix(root)
|
.strip_prefix(root)
|
||||||
.expect("walkdir starts with root");
|
.expect("walkdir starts with root");
|
||||||
|
|
||||||
if !license_globs.match_path(relative) {
|
if !license_globs.match_path(relative) {
|
||||||
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() {
|
|
||||||
|
let file_type = entry.file_type();
|
||||||
|
|
||||||
|
if !(file_type.is_file() || file_type.is_symlink()) {
|
||||||
trace!(
|
trace!(
|
||||||
"Not a file in license files match: {}",
|
"Not a file or symlink in license files match: {}",
|
||||||
relative.user_display()
|
relative.user_display()
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -501,9 +515,35 @@ impl PyProjectToml {
|
||||||
error_on_venv(entry.file_name(), entry.path())?;
|
error_on_venv(entry.file_name(), entry.path())?;
|
||||||
|
|
||||||
debug!("License files match: {}", relative.user_display());
|
debug!("License files match: {}", relative.user_display());
|
||||||
|
|
||||||
|
for (matched, matcher) in license_globs_matched
|
||||||
|
.iter_mut()
|
||||||
|
.zip(license_glob_matchers.iter())
|
||||||
|
{
|
||||||
|
if *matched {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if matcher.is_match(relative) {
|
||||||
|
*matched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
license_files.push(relative.portable_display().to_string());
|
license_files.push(relative.portable_display().to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some((pattern, _)) = license_globs_parsed
|
||||||
|
.into_iter()
|
||||||
|
.zip(license_globs_matched)
|
||||||
|
.find(|(_, matched)| !matched)
|
||||||
|
{
|
||||||
|
return Err(ValidationError::LicenseGlobNoMatches {
|
||||||
|
field: "project.license-files".to_string(),
|
||||||
|
glob: pattern.to_string(),
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
for license_file in &license_files {
|
for license_file in &license_files {
|
||||||
let file_path = root.join(license_file);
|
let file_path = root.join(license_file);
|
||||||
let bytes = fs_err::read(&file_path)?;
|
let bytes = fs_err::read(&file_path)?;
|
||||||
|
|
|
||||||
|
|
@ -760,6 +760,51 @@ fn complex_namespace_packages() -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn license_glob_without_matches_errors() -> Result<()> {
|
||||||
|
let context = TestContext::new("3.12");
|
||||||
|
|
||||||
|
let project = context.temp_dir.child("missing-license");
|
||||||
|
context
|
||||||
|
.init()
|
||||||
|
.arg("--lib")
|
||||||
|
.arg(project.path())
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
project
|
||||||
|
.child("LICENSE.txt")
|
||||||
|
.write_str("permissive license")?;
|
||||||
|
|
||||||
|
project.child("pyproject.toml").write_str(indoc! {r#"
|
||||||
|
[project]
|
||||||
|
name = "missing-license"
|
||||||
|
version = "1.0.0"
|
||||||
|
license-files = ["abc", "LICENSE.txt"]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["uv_build>=0.7,<10000"]
|
||||||
|
build-backend = "uv_build"
|
||||||
|
"#
|
||||||
|
})?;
|
||||||
|
|
||||||
|
uv_snapshot!(context
|
||||||
|
.build_backend()
|
||||||
|
.arg("build-wheel")
|
||||||
|
.arg(context.temp_dir.path())
|
||||||
|
.current_dir(project.path()), @r###"
|
||||||
|
success: false
|
||||||
|
exit_code: 2
|
||||||
|
----- stdout -----
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
error: Invalid pyproject.toml
|
||||||
|
Caused by: `project.license-files` glob `abc` did not match any files
|
||||||
|
"###);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn license_file_must_be_utf8() -> Result<()> {
|
fn license_file_must_be_utf8() -> Result<()> {
|
||||||
let context = TestContext::new("3.12");
|
let context = TestContext::new("3.12");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue