use crate::pyproject_toml::Format;
use anyhow::{Context, Result, bail};
use std::path::{Component, Path, PathBuf};
pub(crate) struct IncludeMatch {
pub source: PathBuf,
pub target: PathBuf,
}
pub(crate) fn resolve_include_matches(
pattern: &str,
format: Format,
project_root: &Path,
python_dir: &Path,
) -> Result<Vec<IncludeMatch>> {
validate_pattern(pattern)?;
let mut matches = glob_files(project_root, pattern)?;
if matches.is_empty() && python_dir != project_root {
matches = glob_files(python_dir, pattern)?;
}
let matches = matches
.into_iter()
.map(|(source, matched_root)| {
let target = map_target(format, &source, matched_root, project_root, python_dir)?;
Ok(IncludeMatch { source, target })
})
.collect::<Result<Vec<_>>>()?;
Ok(matches)
}
fn map_target(
format: Format,
source: &Path,
matched_root: &Path,
project_root: &Path,
python_dir: &Path,
) -> Result<PathBuf> {
match format {
Format::Wheel => {
if python_dir != project_root && source.starts_with(python_dir) {
Ok(source.strip_prefix(python_dir)?.to_path_buf())
} else {
Ok(source.strip_prefix(matched_root)?.to_path_buf())
}
}
Format::Sdist => {
let relative = source.strip_prefix(matched_root)?;
if matched_root == python_dir && python_dir != project_root {
let py_prefix = match python_dir.strip_prefix(project_root) {
Ok(rel) => rel.to_path_buf(),
Err(_) => {
let basename = python_dir.file_name().with_context(|| {
format!(
"python-source `{}` has no final path component",
python_dir.display()
)
})?;
PathBuf::from(basename)
}
};
Ok(py_prefix.join(relative))
} else {
Ok(relative.to_path_buf())
}
}
}
}
fn glob_files<'a>(root: &'a Path, pattern: &str) -> Result<Vec<(PathBuf, &'a Path)>> {
let escaped_root = PathBuf::from(glob::Pattern::escape(root.to_string_lossy().as_ref()));
let full_pattern = escaped_root.join(pattern);
let mut matches = Vec::new();
for source in glob::glob(&full_pattern.to_string_lossy())
.with_context(|| format!("Invalid glob pattern: {pattern}"))?
.filter_map(Result::ok)
{
if source.is_dir() {
continue;
}
matches.push((source, root));
}
Ok(matches)
}
pub(crate) fn resolve_out_dir_includes(
pattern: &str,
out_dir: &Path,
to: &str,
) -> Result<Vec<IncludeMatch>> {
validate_pattern(pattern)?;
validate_pattern(to)?;
let matches = glob_files(out_dir, pattern)?;
matches
.into_iter()
.map(|(source, matched_root)| {
let relative = source.strip_prefix(matched_root)?;
let target = PathBuf::from(to).join(relative);
Ok(IncludeMatch { source, target })
})
.collect()
}
fn validate_pattern(pattern: &str) -> Result<()> {
for component in Path::new(pattern).components() {
match component {
Component::Normal(_) => {}
Component::ParentDir => {
bail!(
"include/exclude pattern must not contain `..`, got: {pattern}. \
Use a pattern relative to the project root or python-source directory."
);
}
_ => {
bail!("include/exclude pattern must be a relative path, got: {pattern}");
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use fs_err as fs;
use tempfile::TempDir;
#[test]
fn test_validate_pattern() {
assert!(validate_pattern("/foo/bar").is_err());
assert!(validate_pattern("../foo/bar").is_err());
assert!(validate_pattern("foo/../bar").is_err());
assert!(validate_pattern("./foo/bar").is_err());
assert!(validate_pattern("foo/bar").is_ok());
assert!(validate_pattern("**/*.html").is_ok());
assert!(validate_pattern("pyfoo/bar.html").is_ok());
}
fn setup_tree(files: &[&str]) -> TempDir {
let dir = TempDir::new().unwrap();
for file in files {
let path = dir.path().join(file);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "content").unwrap();
}
dir
}
#[test]
fn test_resolve_matches_from_primary_root() {
let dir = setup_tree(&["pkg/data.txt"]);
let root = dir.path();
let matches = resolve_include_matches("pkg/data.txt", Format::Wheel, root, root).unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].target, Path::new("pkg/data.txt"));
}
#[test]
fn test_resolve_python_dir_fallback() {
let dir = setup_tree(&["src/python/pkg/data.txt"]);
let root = dir.path();
let python_dir = root.join("src/python");
let wheel =
resolve_include_matches("pkg/data.txt", Format::Wheel, root, &python_dir).unwrap();
assert_eq!(wheel.len(), 1);
assert_eq!(wheel[0].target, Path::new("pkg/data.txt"));
assert_eq!(wheel[0].source, python_dir.join("pkg/data.txt"));
let sdist =
resolve_include_matches("pkg/data.txt", Format::Sdist, root, &python_dir).unwrap();
assert_eq!(sdist.len(), 1);
assert_eq!(sdist[0].target, Path::new("src/python/pkg/data.txt"));
}
#[test]
fn test_wheel_strips_python_dir_for_explicit_path() {
let dir = setup_tree(&["src/python/pkg/data.txt"]);
let root = dir.path();
let python_dir = root.join("src/python");
let matches =
resolve_include_matches("src/python/pkg/data.txt", Format::Wheel, root, &python_dir)
.unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].target, Path::new("pkg/data.txt"));
}
#[test]
fn test_primary_root_takes_precedence() {
let dir = setup_tree(&["pkg/data.txt", "src/python/pkg/data.txt"]);
let root = dir.path();
let python_dir = root.join("src/python");
let matches =
resolve_include_matches("pkg/data.txt", Format::Wheel, root, &python_dir).unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].source, root.join("pkg/data.txt"));
}
#[test]
fn test_python_dir_outside_project_root() {
let python_tmp = setup_tree(&["pkg/data.txt"]);
let project_tmp = TempDir::new().unwrap();
let python_dir = python_tmp.path();
let project_root = project_tmp.path();
let sdist =
resolve_include_matches("pkg/data.txt", Format::Sdist, project_root, python_dir)
.unwrap();
assert_eq!(sdist.len(), 1);
let python_dir_name = python_dir.file_name().unwrap();
assert_eq!(
sdist[0].target,
Path::new(python_dir_name).join("pkg/data.txt")
);
let wheel =
resolve_include_matches("pkg/data.txt", Format::Wheel, project_root, python_dir)
.unwrap();
assert_eq!(wheel.len(), 1);
assert_eq!(wheel[0].target, Path::new("pkg/data.txt"));
}
#[test]
fn test_out_dir_validates_to_parameter() {
let dir = setup_tree(&["gen.txt"]);
assert!(resolve_out_dir_includes("gen.txt", dir.path(), "../escape").is_err());
assert!(resolve_out_dir_includes("gen.txt", dir.path(), "/absolute").is_err());
assert!(resolve_out_dir_includes("gen.txt", dir.path(), "pkg/").is_ok());
}
#[test]
fn test_no_match_and_dir_only() {
let dir = TempDir::new().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("pkg/subdir")).unwrap();
assert!(
resolve_include_matches("nonexistent.txt", Format::Wheel, root, root)
.unwrap()
.is_empty()
);
assert!(
resolve_include_matches("pkg/*", Format::Wheel, root, root)
.unwrap()
.is_empty()
);
}
}