use crate::pyproject_toml::Format;
use crate::{ModuleWriter, PyProjectToml, SDistWriter, VirtualWriter};
use anyhow::{Context, Result, bail};
use normpath::PathExt as _;
use path_slash::PathExt as _;
use pyproject_toml::check_pep639_glob;
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
use tracing::{debug, trace};
use super::SdistContext;
use super::cargo_toml_rewrite::parse_toml_file;
use super::utils::is_compiled_artifact;
pub(super) type PyprojectPathRewrite = (&'static str, String);
fn parent_relative_metadata_path(field: &str, path: &Path) -> Result<bool> {
if path
.components()
.any(|c| matches!(c, Component::Prefix(_) | Component::RootDir))
{
bail!(
"`project.{field}.file` path `{}` must be relative to pyproject.toml",
path.display()
);
}
Ok(path.components().any(|c| matches!(c, Component::ParentDir)))
}
fn project_metadata_paths(
project: &pyproject_toml::Project,
) -> Vec<(&'static str, &'static str, &Path)> {
let mut paths = Vec::new();
match project.readme.as_ref() {
Some(pyproject_toml::ReadMe::RelativePath(file)) => {
paths.push(("readme", "project.readme", Path::new(file.as_str())));
}
Some(pyproject_toml::ReadMe::Table {
file: Some(file), ..
}) => {
paths.push(("readme", "project.readme.file", Path::new(file.as_str())));
}
_ => {}
}
if let Some(pyproject_toml::License::File { file }) = project.license.as_ref() {
paths.push(("license", "project.license.file", file.as_path()));
}
paths
}
pub(super) fn add_pyproject_metadata_files(
writer: &mut VirtualWriter<SDistWriter>,
pyproject: &PyProjectToml,
ctx: &SdistContext<'_>,
allowed_metadata_root: &Path,
) -> Result<Vec<PyprojectPathRewrite>> {
let mut rewrites = Vec::new();
let Some(project) = pyproject.project.as_ref() else {
return Ok(rewrites);
};
let allowed_metadata_root = allowed_metadata_root.normalize()?.into_path_buf();
let project_root = ctx.project_root.normalize()?.into_path_buf();
for (field, field_name, path) in project_metadata_paths(project) {
let mut source = ctx.pyproject_dir.join(path);
let mut target_relative = path.to_path_buf();
if parent_relative_metadata_path(field_name, path)? {
if ctx.pyproject_dir == ctx.sdist_root {
bail!(
"`{field_name}` path `{}` must not contain `..` when pyproject.toml is already at the sdist root",
path.display()
);
}
source = source.normalize()?.into_path_buf();
if !source.starts_with(allowed_metadata_root.as_path()) {
bail!(
"`{field_name}` path `{}` resolves outside allowed metadata root `{}`",
path.display(),
allowed_metadata_root.display()
);
}
target_relative = source
.strip_prefix(&project_root)
.with_context(|| {
format!(
"`{field_name}` path `{}` resolves outside sdist project root `{}`",
path.display(),
project_root.display()
)
})?
.to_path_buf();
let rewrite_path = target_relative
.to_slash()
.with_context(|| {
format!(
"`{field_name}` path `{}` is not valid UTF-8",
path.display()
)
})?
.into_owned();
rewrites.push((field, rewrite_path));
}
let target = ctx.root_dir.join(target_relative);
if !writer.contains_target(&target) {
writer.add_file(target, source, false)?;
}
}
Ok(rewrites)
}
pub(super) fn reject_parent_relative_metadata_paths(pyproject: &PyProjectToml) -> Result<()> {
let Some(project) = pyproject.project.as_ref() else {
return Ok(());
};
for (_, field_name, path) in project_metadata_paths(project) {
if parent_relative_metadata_path(field_name, path)? {
bail!(
"`{field_name}` path `{}` must not contain `..` when using the git sdist generator",
path.display()
);
}
}
Ok(())
}
pub(super) fn add_pyproject_toml(
writer: &mut VirtualWriter<SDistWriter>,
ctx: &SdistContext<'_>,
pyproject_toml_path: &Path,
metadata_rewrites: &[PyprojectPathRewrite],
) -> Result<()> {
if ctx.pyproject_dir != ctx.sdist_root {
let relative_manifest_path = ctx.relative_main_crate_manifest_dir.join("Cargo.toml");
let python_dir = &ctx.project.project_layout.python_dir;
let relative_python_source = if python_dir != &ctx.pyproject_dir {
python_dir
.strip_prefix(&ctx.pyproject_dir)
.or_else(|_| python_dir.strip_prefix(&ctx.project_root))
.ok()
.map(|p| p.to_path_buf())
} else {
None
};
let rewritten = rewrite_pyproject_toml(
pyproject_toml_path,
&relative_manifest_path,
relative_python_source.as_deref(),
metadata_rewrites,
)?;
writer.add_bytes(
ctx.root_dir.join("pyproject.toml"),
Some(pyproject_toml_path),
rewritten.as_bytes(),
false,
)?;
} else {
writer.add_file(
ctx.root_dir.join("pyproject.toml"),
pyproject_toml_path,
false,
)?;
}
Ok(())
}
pub(super) fn add_python_sources(
writer: &mut VirtualWriter<SDistWriter>,
ctx: &SdistContext<'_>,
) -> Result<()> {
let project = ctx.project;
let mut python_packages = Vec::new();
if let Some(python_module) = project.project_layout.python_module.as_ref() {
trace!("Resolved python module: {}", python_module.display());
python_packages.push(python_module.to_path_buf());
}
for package in &project.project_layout.python_packages {
let package_path = project.project_layout.python_dir.join(package);
if python_packages.contains(&package_path) {
continue;
}
trace!("Resolved python package: {}", package_path.display());
python_packages.push(package_path);
}
for package in python_packages {
for entry in ignore::Walk::new(package) {
let source = entry?.into_path();
if is_compiled_artifact(&source) {
debug!("Ignoring {}", source.display());
continue;
}
let relative = source
.strip_prefix(&ctx.pyproject_dir)
.or_else(|_| source.strip_prefix(&ctx.project_root))
.with_context(|| {
format!(
"Python source file `{}` is outside both pyproject dir `{}` and project root `{}`",
source.display(),
ctx.pyproject_dir.display(),
ctx.project_root.display(),
)
})?;
if !source.is_dir() {
writer.add_file(ctx.root_dir.join(relative), &source, false)?;
}
}
}
Ok(())
}
pub(super) fn add_pyproject_metadata(
writer: &mut VirtualWriter<SDistWriter>,
pyproject: &PyProjectToml,
pyproject_dir: &Path,
root_dir: &Path,
python_dir: &Path,
) -> Result<()> {
if let Some(project) = pyproject.project.as_ref()
&& let Some(license_files) = &project.license_files
{
let escaped_pyproject_dir =
PathBuf::from(glob::Pattern::escape(pyproject_dir.to_str().unwrap()));
let mut seen = HashSet::new();
for license_glob in license_files {
check_pep639_glob(license_glob)?;
for license_path in
glob::glob(&escaped_pyproject_dir.join(license_glob).to_string_lossy())?
{
let license_path = license_path?;
if !license_path.is_file() {
continue;
}
let license_path = license_path
.strip_prefix(pyproject_dir)
.expect("matched path starts with glob root")
.to_path_buf();
if seen.insert(license_path.clone()) {
debug!("Including license file `{}`", license_path.display());
writer.add_file(
root_dir.join(&license_path),
pyproject_dir.join(&license_path),
false,
)?;
}
}
}
}
if let Some(glob_patterns) = pyproject.include() {
for pattern in glob_patterns
.iter()
.filter_map(|glob_pattern| glob_pattern.targets(Format::Sdist))
{
eprintln!("📦 Including files matching \"{pattern}\"");
let matches = crate::module_writer::glob::resolve_include_matches(
pattern,
Format::Sdist,
pyproject_dir,
python_dir,
)?;
for m in matches {
writer.add_file(root_dir.join(&m.target), m.source, false)?;
}
}
}
Ok(())
}
fn rewrite_pyproject_toml(
pyproject_toml_path: &Path,
relative_manifest_path: &Path,
relative_python_source: Option<&Path>,
metadata_rewrites: &[PyprojectPathRewrite],
) -> Result<String> {
let mut data = parse_toml_file(pyproject_toml_path, "pyproject.toml")?;
let tool = data
.entry("tool")
.or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
.as_table_like_mut()
.with_context(|| {
format!(
"`[tool]` must be a table in {}",
pyproject_toml_path.display()
)
})?;
let maturin = tool
.entry("maturin")
.or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
.as_table_like_mut()
.with_context(|| {
format!(
"`[tool.maturin]` must be a table in {}",
pyproject_toml_path.display()
)
})?;
maturin.remove("manifest-path");
let manifest_path_str = relative_manifest_path.to_slash().with_context(|| {
format!(
"manifest-path `{}` is not valid UTF-8",
relative_manifest_path.display()
)
})?;
maturin.insert(
"manifest-path",
toml_edit::value(manifest_path_str.as_ref()),
);
if let Some(python_source) = relative_python_source {
maturin.remove("python-source");
let python_source_str = python_source.to_slash().with_context(|| {
format!(
"python-source path `{}` is not valid UTF-8",
python_source.display()
)
})?;
maturin.insert(
"python-source",
toml_edit::value(python_source_str.as_ref()),
);
}
if !metadata_rewrites.is_empty() {
let project = data
.entry("project")
.or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
.as_table_like_mut()
.with_context(|| {
format!(
"`[project]` must be a table in {}",
pyproject_toml_path.display()
)
})?;
for rewrite in metadata_rewrites {
rewrite_pyproject_field_path(project, rewrite.0, "file", &rewrite.1)?;
}
}
Ok(data.to_string())
}
fn rewrite_pyproject_field_path(
project: &mut dyn toml_edit::TableLike,
field: &str,
inner_field: &str,
new_path: &str,
) -> Result<()> {
let Some(item) = project.get_mut(field) else {
return Ok(());
};
match item {
toml_edit::Item::Value(toml_edit::Value::String(s)) => {
*s = toml_edit::Formatted::new(new_path.to_string());
}
toml_edit::Item::Value(toml_edit::Value::InlineTable(table)) => {
table.insert(
inner_field,
toml_edit::Value::String(toml_edit::Formatted::new(new_path.to_string())),
);
}
toml_edit::Item::Table(table) => {
table.insert(inner_field, toml_edit::value(new_path));
}
_ => bail!("unexpected shape for `project.{field}` in pyproject.toml"),
}
Ok(())
}