use crate::PyProjectToml;
use anyhow::{Context, Result, bail, format_err};
use fs_err as fs;
use indexmap::IndexMap;
use normpath::PathExt;
use once_cell::sync::Lazy;
use pep440_rs::{Version, VersionSpecifiers};
use pep508_rs::{
ExtraName, ExtraOperator, MarkerExpression, MarkerTree, MarkerValueExtra, Requirement,
};
use pyproject_toml::{Contact, License, check_pep639_glob};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::str;
use std::str::FromStr;
use tracing::debug;
#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)]
pub struct WheelMetadata {
pub metadata24: Metadata24,
pub scripts: HashMap<String, String>,
pub module_name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
#[allow(missing_docs)]
pub struct Metadata24 {
pub metadata_version: String,
pub name: String,
pub version: Version,
pub platform: Vec<String>,
pub supported_platform: Vec<String>,
pub summary: Option<String>,
pub description: Option<String>,
pub description_content_type: Option<String>,
pub keywords: Option<String>,
pub home_page: Option<String>,
pub download_url: Option<String>,
pub author: Option<String>,
pub author_email: Option<String>,
pub maintainer: Option<String>,
pub maintainer_email: Option<String>,
pub license: Option<String>,
pub license_expression: Option<String>,
pub license_files: Vec<PathBuf>,
#[serde(skip)]
pub license_file_sources: HashMap<PathBuf, PathBuf>,
pub classifiers: Vec<String>,
pub requires_dist: Vec<Requirement>,
pub provides_dist: Vec<String>,
pub obsoletes_dist: Vec<String>,
pub requires_python: Option<VersionSpecifiers>,
pub requires_external: Vec<String>,
pub project_url: IndexMap<String, String>,
pub provides_extra: Vec<String>,
pub scripts: IndexMap<String, String>,
pub gui_scripts: IndexMap<String, String>,
pub entry_points: IndexMap<String, IndexMap<String, String>>,
}
impl Metadata24 {
pub fn new(name: String, version: Version) -> Self {
Self {
metadata_version: "2.4".to_string(),
name,
version,
platform: Vec::new(),
supported_platform: Vec::new(),
summary: None,
description: None,
description_content_type: None,
keywords: None,
home_page: None,
download_url: None,
author: None,
author_email: None,
maintainer: None,
maintainer_email: None,
license: None,
license_expression: None,
license_files: Vec::new(),
license_file_sources: HashMap::new(),
classifiers: Vec::new(),
requires_dist: Vec::new(),
provides_dist: Vec::new(),
obsoletes_dist: Vec::new(),
requires_python: None,
requires_external: Vec::new(),
project_url: IndexMap::new(),
provides_extra: Vec::new(),
scripts: IndexMap::new(),
gui_scripts: IndexMap::new(),
entry_points: IndexMap::new(),
}
}
}
const PLAINTEXT_CONTENT_TYPE: &str = "text/plain; charset=UTF-8";
const GFM_CONTENT_TYPE: &str = "text/markdown; charset=UTF-8; variant=GFM";
fn path_to_content_type(path: &Path) -> String {
path.extension()
.map_or(String::from(PLAINTEXT_CONTENT_TYPE), |ext| {
let ext = ext.to_string_lossy().to_lowercase();
let type_str = match ext.as_str() {
"rst" => "text/x-rst; charset=UTF-8",
"md" => GFM_CONTENT_TYPE,
"markdown" => GFM_CONTENT_TYPE,
_ => PLAINTEXT_CONTENT_TYPE,
};
String::from(type_str)
})
}
fn normalize_license_file_path(base_dir: &Path, absolute_license_path: &Path) -> Result<PathBuf> {
let file_name = absolute_license_path
.file_name()
.context("license file path has no filename")?;
let Ok(relative) = absolute_license_path.strip_prefix(base_dir) else {
return Ok(PathBuf::from(file_name));
};
if relative
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Ok(PathBuf::from(file_name));
}
Ok(relative.to_path_buf())
}
fn validate_safe_relative_license_path(path: &Path, source: &str) -> Result<()> {
if path.components().any(|c| {
matches!(
c,
std::path::Component::ParentDir
| std::path::Component::Prefix(_)
| std::path::Component::RootDir
)
}) {
bail!(
"{source} must be a safe relative path inside the project, got `{}`",
path.display()
);
}
Ok(())
}
impl Metadata24 {
pub fn merge_pyproject_toml(
&mut self,
pyproject_dir: impl AsRef<Path>,
pyproject_toml: &PyProjectToml,
) -> Result<()> {
let pyproject_dir = pyproject_dir.as_ref();
if let Some(project) = &pyproject_toml.project {
let dynamic: HashSet<&str> = project
.dynamic
.as_ref()
.map(|x| x.iter().map(AsRef::as_ref).collect())
.unwrap_or_default();
if dynamic.contains("name") {
bail!("`project.dynamic` must not specify `name` in pyproject.toml");
}
self.clear_non_dynamic_fields(&dynamic);
self.name.clone_from(&project.name);
self.merge_version(pyproject_toml, project)?;
self.merge_readme(pyproject_dir, project)?;
self.merge_license(pyproject_dir, project)?;
self.merge_people(project);
if let Some(description) = &project.description {
self.summary = Some(description.clone());
}
if let Some(requires_python) = &project.requires_python {
self.requires_python = Some(requires_python.clone());
}
if let Some(keywords) = &project.keywords {
self.keywords = Some(keywords.join(","));
}
if let Some(classifiers) = &project.classifiers {
self.classifiers.clone_from(classifiers);
}
if let Some(urls) = &project.urls {
self.project_url.clone_from(urls);
}
if let Some(dependencies) = &project.dependencies {
self.requires_dist.clone_from(dependencies);
}
self.merge_optional_dependencies(project)?;
self.merge_entry_points(project)?;
}
self.normalize_license_paths(pyproject_dir)?;
Ok(())
}
fn clear_non_dynamic_fields(&mut self, dynamic: &HashSet<&str>) {
if !dynamic.contains("description") {
self.summary = None;
}
if !dynamic.contains("authors") {
self.author = None;
self.author_email = None;
}
if !dynamic.contains("maintainers") {
self.maintainer = None;
self.maintainer_email = None;
}
if !dynamic.contains("keywords") {
self.keywords = None;
}
if !dynamic.contains("urls") {
self.project_url.clear();
}
if !dynamic.contains("license") {
self.license = None;
self.license_expression = None;
}
if !dynamic.contains("classifiers") {
self.classifiers.clear();
}
if !dynamic.contains("readme") {
self.description = None;
self.description_content_type = None;
}
if !dynamic.contains("requires-python") {
self.requires_python = None;
}
}
fn merge_version(
&mut self,
pyproject_toml: &PyProjectToml,
project: &pyproject_toml::Project,
) -> Result<()> {
let version_ok = pyproject_toml.warn_invalid_version_info();
if !version_ok {
let current_major = env!("CARGO_PKG_VERSION_MAJOR").parse::<usize>().unwrap();
if current_major > 1 {
bail!("Invalid version information in pyproject.toml.");
}
}
if let Some(version) = &project.version {
self.version = version.clone();
}
Ok(())
}
fn merge_readme(
&mut self,
pyproject_dir: &Path,
project: &pyproject_toml::Project,
) -> Result<()> {
match &project.readme {
Some(pyproject_toml::ReadMe::RelativePath(readme_path)) => {
let readme_path = pyproject_dir.join(readme_path);
let description = Some(fs::read_to_string(&readme_path).context(format!(
"Failed to read readme specified in pyproject.toml, which should be at {}",
readme_path.display()
))?);
self.description = description;
self.description_content_type = Some(path_to_content_type(&readme_path));
}
Some(pyproject_toml::ReadMe::Table {
file,
text,
content_type,
}) => {
if file.is_some() && text.is_some() {
bail!(
"file and text fields of 'project.readme' are mutually-exclusive, only one of them should be specified"
);
}
if let Some(readme_path) = file {
let readme_path = pyproject_dir.join(readme_path);
let description = Some(fs::read_to_string(&readme_path).context(format!(
"Failed to read readme specified in pyproject.toml, which should be at {}",
readme_path.display()
))?);
self.description = description;
}
if let Some(description) = text {
self.description = Some(description.clone());
}
self.description_content_type.clone_from(content_type);
}
None => {}
}
Ok(())
}
fn merge_license(
&mut self,
pyproject_dir: &Path,
project: &pyproject_toml::Project,
) -> Result<()> {
if let Some(license) = &project.license {
match license {
License::Spdx(license_expr) => self.license_expression = Some(license_expr.clone()),
License::File { file } => {
let file = file.to_path_buf();
validate_safe_relative_license_path(&file, "`project.license.file`")?;
self.license_file_sources.remove(&file);
if !self.license_files.contains(&file) {
self.license_files.push(file);
}
}
License::Text { text } => self.license = Some(text.clone()),
}
}
if let Some(license_files) = &project.license_files {
let escaped_pyproject_dir =
PathBuf::from(glob::Pattern::escape(pyproject_dir.to_str().unwrap()));
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();
self.license_file_sources.remove(&license_path);
if !self.license_files.contains(&license_path) {
debug!("Including license file `{}`", license_path.display());
self.license_files.push(license_path);
}
}
}
} else {
let license_include_targets = ["LICEN[CS]E*", "COPYING*", "NOTICE*", "AUTHORS*"];
let escaped_manifest_string = glob::Pattern::escape(pyproject_dir.to_str().unwrap());
let escaped_manifest_path = Path::new(&escaped_manifest_string);
for pattern in license_include_targets.iter() {
for license_path in
glob::glob(&escaped_manifest_path.join(pattern).to_string_lossy())?
.filter_map(Result::ok)
{
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 !self.license_files.contains(&license_path) {
eprintln!("📦 Including license file `{}`", license_path.display());
self.license_files.push(license_path);
}
}
}
}
Ok(())
}
fn merge_people(&mut self, project: &pyproject_toml::Project) {
if let Some(authors) = &project.authors {
let (names, emails) = split_contacts(authors);
if names.is_some() {
self.author = names;
}
if emails.is_some() {
self.author_email = emails;
}
}
if let Some(maintainers) = &project.maintainers {
let (names, emails) = split_contacts(maintainers);
if names.is_some() {
self.maintainer = names;
}
if emails.is_some() {
self.maintainer_email = emails;
}
}
}
fn merge_optional_dependencies(&mut self, project: &pyproject_toml::Project) -> Result<()> {
if let Some(dependencies) = &project.optional_dependencies {
for (extra, deps) in dependencies {
self.provides_extra.push(extra.clone());
for dep in deps {
let mut dep = dep.clone();
let new_extra = MarkerExpression::Extra {
operator: ExtraOperator::Equal,
name: MarkerValueExtra::Extra(
ExtraName::new(extra.clone())
.with_context(|| format!("invalid extra name: {extra}"))?,
),
};
dep.marker.and(MarkerTree::expression(new_extra));
self.requires_dist.push(dep);
}
}
}
Ok(())
}
fn merge_entry_points(&mut self, project: &pyproject_toml::Project) -> Result<()> {
if let Some(scripts) = &project.scripts {
self.scripts.clone_from(scripts);
}
if let Some(gui_scripts) = &project.gui_scripts {
self.gui_scripts.clone_from(gui_scripts);
}
if let Some(entry_points) = &project.entry_points {
if entry_points.contains_key("console_scripts") {
bail!("console_scripts is not allowed in project.entry-points table");
}
if entry_points.contains_key("gui_scripts") {
bail!("gui_scripts is not allowed in project.entry-points table");
}
self.entry_points.clone_from(entry_points);
}
Ok(())
}
fn normalize_license_paths(&mut self, pyproject_dir: &Path) -> Result<()> {
let absolute_license_files = self
.license_files
.iter()
.filter(|p| p.is_absolute())
.cloned()
.collect::<Vec<_>>();
if !absolute_license_files.is_empty() {
self.license_files.retain(|p| !p.is_absolute());
for absolute_license_file in absolute_license_files {
let wheel_path =
normalize_license_file_path(pyproject_dir, &absolute_license_file)?;
if !self.license_files.contains(&wheel_path) {
self.license_file_sources
.insert(wheel_path.clone(), absolute_license_file);
self.license_files.push(wheel_path);
}
}
}
Ok(())
}
pub fn from_cargo_toml(
manifest_path: impl AsRef<Path>,
cargo_metadata: &cargo_metadata::Metadata,
) -> Result<Metadata24> {
let package = cargo_metadata
.root_package()
.context("Expected cargo to return metadata with root_package")?;
let authors = package.authors.join(", ");
let author_email = if authors.contains('@') {
Some(authors.clone())
} else {
None
};
let mut description: Option<String> = None;
let mut description_content_type: Option<String> = None;
if package.readme == Some("false".into()) {
} else if let Some(ref readme) = package.readme {
let readme_path = manifest_path.as_ref().join(readme);
description = Some(fs::read_to_string(&readme_path).context(format!(
"Failed to read Readme specified in Cargo.toml, which should be at {}",
readme_path.display()
))?);
description_content_type = Some(path_to_content_type(&readme_path));
} else {
for readme_guess in ["README.md", "README.txt", "README.rst", "README"] {
let guessed_readme = manifest_path.as_ref().join(readme_guess);
if guessed_readme.exists() {
let context = format!(
"Readme at {} exists, but can't be read",
guessed_readme.display()
);
description = Some(fs::read_to_string(&guessed_readme).context(context)?);
description_content_type = Some(path_to_content_type(&guessed_readme));
break;
}
}
};
let name = package.name.clone();
let mut project_url = IndexMap::new();
if let Some(homepage) = package.homepage.as_ref() {
project_url.insert("Homepage".to_string(), homepage.clone());
}
if let Some(documentation) = package.documentation.as_ref() {
project_url.insert("Documentation".to_string(), documentation.clone());
}
if let Some(repository) = package.repository.as_ref() {
project_url.insert("Source Code".to_string(), repository.clone());
}
let mut license_file_sources = HashMap::new();
let license_files = if let Some(license_file) = package.license_file.as_ref() {
let absolute_license_path = manifest_path
.as_ref()
.join(license_file)
.normalize()?
.into_path_buf();
let workspace_root = cargo_metadata
.workspace_root
.as_std_path()
.normalize()?
.into_path_buf();
if !absolute_license_path.starts_with(&workspace_root) {
bail!(
"license file `{license_file}` specified in `{}` resolves outside Cargo workspace root `{}`",
manifest_path.as_ref().display(),
workspace_root.display()
);
}
if !absolute_license_path.is_file() {
bail!(
"license file `{license_file}` specified in `{}` is not a file",
manifest_path.as_ref().display()
);
}
let manifest_dir = manifest_path.as_ref().normalize()?.into_path_buf();
let wheel_path = normalize_license_file_path(&manifest_dir, &absolute_license_path)?;
license_file_sources.insert(wheel_path.clone(), absolute_license_path);
vec![wheel_path]
} else {
Vec::new()
};
let version = Version::from_str(&package.version.to_string()).map_err(|err| {
format_err!(
"Rust version used in Cargo.toml is not a valid python version: {}. \
Note that rust uses [SemVer](https://semver.org/) while python uses \
[PEP 440](https://peps.python.org/pep-0440/), which have e.g. some differences \
when declaring prereleases.",
err
)
})?;
let metadata = Metadata24 {
summary: package.description.as_ref().map(|d| d.trim().into()),
description,
description_content_type,
keywords: if package.keywords.is_empty() {
None
} else {
Some(package.keywords.join(","))
},
home_page: package.homepage.clone(),
download_url: None,
author: if package.authors.is_empty() {
None
} else {
Some(authors)
},
author_email,
license: package.license.clone(),
license_files,
license_file_sources,
project_url,
..Metadata24::new(name.to_string(), version)
};
Ok(metadata)
}
pub fn to_vec(&self) -> Vec<(String, String)> {
let mut fields = vec![
("Metadata-Version", self.metadata_version.clone()),
("Name", self.name.clone()),
("Version", self.version.to_string()),
];
let mut add_vec = |name, values: &[String]| {
for i in values {
fields.push((name, i.clone()));
}
};
add_vec("Platform", &self.platform);
add_vec("Supported-Platform", &self.supported_platform);
add_vec("Classifier", &self.classifiers);
add_vec(
"Requires-Dist",
&self
.requires_dist
.iter()
.map(ToString::to_string)
.collect::<Vec<String>>(),
);
add_vec("Provides-Dist", &self.provides_dist);
add_vec("Obsoletes-Dist", &self.obsoletes_dist);
add_vec("Requires-External", &self.requires_external);
add_vec("Provides-Extra", &self.provides_extra);
let license_files: Vec<String> = self
.license_files
.iter()
.map(|path| path.display().to_string().replace("\\", "/"))
.collect();
add_vec("License-File", &license_files);
let mut add_option = |name, value: &Option<String>| {
if let Some(some) = value.clone() {
fields.push((name, some));
}
};
add_option("Summary", &self.summary);
add_option("Keywords", &self.keywords);
add_option("Home-Page", &self.home_page);
add_option("Download-URL", &self.download_url);
add_option("Author", &self.author);
add_option("Author-email", &self.author_email);
add_option("Maintainer", &self.maintainer);
add_option("Maintainer-email", &self.maintainer_email);
add_option("License-Expression", &self.license_expression);
add_option("License", &self.license.as_deref().map(fold_header));
add_option(
"Requires-Python",
&self
.requires_python
.as_ref()
.map(|requires_python| requires_python.to_string()),
);
add_option("Description-Content-Type", &self.description_content_type);
for (key, value) in self.project_url.iter() {
fields.push(("Project-URL", format!("{key}, {value}")))
}
if let Some(description) = &self.description {
fields.push(("Description", description.clone()));
}
fields
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect()
}
pub fn to_file_contents(&self) -> Result<String> {
let mut fields = self.to_vec();
let mut out = "".to_string();
let body = match fields.last() {
Some((key, description)) if key == "Description" => {
let desc = description.clone();
fields.pop().unwrap();
Some(desc)
}
Some((_, _)) => None,
None => None,
};
for (key, value) in fields {
writeln!(out, "{key}: {value}")?;
}
if let Some(body) = body {
writeln!(out, "\n{body}")?;
}
Ok(out)
}
pub fn get_distribution_escaped(&self) -> String {
static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[-_.]+").unwrap());
RE.replace_all(&self.name, "_").to_lowercase()
}
pub fn get_version_escaped(&self) -> String {
self.version.to_string().replace('-', "_")
}
pub fn get_dist_info_dir(&self) -> PathBuf {
PathBuf::from(format!(
"{}-{}.dist-info",
&self.get_distribution_escaped(),
&self.get_version_escaped()
))
}
pub fn get_data_dir(&self) -> PathBuf {
PathBuf::from(format!(
"{}-{}.data",
&self.get_distribution_escaped(),
&self.get_version_escaped()
))
}
}
fn split_contacts(contacts: &[Contact]) -> (Option<String>, Option<String>) {
let mut names = Vec::with_capacity(contacts.len());
let mut emails = Vec::with_capacity(contacts.len());
for contact in contacts {
match (contact.name(), contact.email()) {
(Some(name), Some(email)) => {
emails.push(escape_email_with_display_name(name, email));
}
(Some(name), None) => {
names.push(name.to_string());
}
(None, Some(email)) => {
emails.push(email.to_string());
}
(None, None) => {}
}
}
let names = if names.is_empty() {
None
} else {
Some(names.join(", "))
};
let emails = if emails.is_empty() {
None
} else {
Some(emails.join(", "))
};
(names, emails)
}
fn escape_email_with_display_name(display_name: &str, email: &str) -> String {
if display_name.chars().any(|c| {
matches!(
c,
'(' | ')' | '<' | '>' | '@' | ',' | ';' | ':' | '\\' | '"' | '.' | '[' | ']'
)
}) {
return format!(
"\"{}\" <{email}>",
display_name.replace('\\', "\\\\").replace('\"', "\\\"")
);
}
format!("{display_name} <{email}>")
}
fn fold_header(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let options = textwrap::Options::new(78)
.initial_indent("")
.subsequent_indent("\t");
for (i, line) in textwrap::wrap(text, options).iter().enumerate() {
if i > 0 {
result.push_str("\r\n");
}
let line = line.trim_end();
if line.is_empty() {
result.push('\t');
} else {
result.push_str(line);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::test_crate_path;
use cargo_metadata::MetadataCommand;
use expect_test::{Expect, expect};
use indoc::indoc;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn assert_metadata_from_cargo_toml(
readme: &str,
cargo_toml: &str,
expected: Expect,
) -> Metadata24 {
let crate_dir = tempfile::tempdir().unwrap();
let crate_path = crate_dir.path();
let manifest_path = crate_path.join("Cargo.toml");
fs::create_dir(crate_path.join("src")).unwrap();
fs::write(crate_path.join("src/lib.rs"), "").unwrap();
let readme_path = crate_path.join("README.md");
fs::write(&readme_path, readme.as_bytes()).unwrap();
let readme_path = if cfg!(windows) {
readme_path.to_str().unwrap().replace('\\', "/")
} else {
readme_path.to_str().unwrap().to_string()
};
let toml_with_path = cargo_toml.replace("REPLACE_README_PATH", &readme_path);
fs::write(&manifest_path, toml_with_path).unwrap();
let cargo_metadata = MetadataCommand::new()
.manifest_path(manifest_path)
.exec()
.unwrap();
let metadata = Metadata24::from_cargo_toml(crate_path, &cargo_metadata).unwrap();
let actual = metadata.to_file_contents().unwrap();
expected.assert_eq(&actual);
assert!(
cargo_toml.contains("name = \"info-project\"")
&& cargo_toml.contains("version = \"0.1.0\""),
"cargo_toml name and version string do not match hardcoded values, test will fail",
);
metadata
}
#[test]
fn test_metadata_from_cargo_toml() {
let readme = indoc!(
r#"
# Some test package
This is the readme for a test package
"#
);
let cargo_toml = indoc!(
r#"
[package]
authors = ["konstin <konstin@mailbox.org>"]
name = "info-project"
version = "0.1.0"
description = """
A test project
"""
homepage = "https://example.org"
readme = "REPLACE_README_PATH"
keywords = ["ffi", "test"]
[lib]
crate-type = ["cdylib"]
name = "pyo3_pure"
"#
);
let expected = expect![[r#"
Metadata-Version: 2.4
Name: info-project
Version: 0.1.0
Summary: A test project
Keywords: ffi,test
Home-Page: https://example.org
Author: konstin <konstin@mailbox.org>
Author-email: konstin <konstin@mailbox.org>
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Homepage, https://example.org
# Some test package
This is the readme for a test package
"#]];
assert_metadata_from_cargo_toml(readme, cargo_toml, expected);
}
#[test]
fn test_path_to_content_type() {
for (filename, expected) in &[
("r.md", GFM_CONTENT_TYPE),
("r.markdown", GFM_CONTENT_TYPE),
("r.mArKdOwN", GFM_CONTENT_TYPE),
("r.rst", "text/x-rst; charset=UTF-8"),
("r.somethingelse", PLAINTEXT_CONTENT_TYPE),
("r", PLAINTEXT_CONTENT_TYPE),
] {
let result = path_to_content_type(&PathBuf::from(filename));
assert_eq!(
&result.as_str(),
expected,
"Wrong content type for file '{}'. Expected '{}', got '{}'",
filename,
expected,
result
);
}
}
#[test]
fn test_merge_metadata_from_pyproject_toml() {
let manifest_dir = test_crate_path("pyo3-pure");
let cargo_metadata = MetadataCommand::new()
.manifest_path(manifest_dir.join("Cargo.toml"))
.exec()
.unwrap();
let mut metadata = Metadata24::from_cargo_toml(&manifest_dir, &cargo_metadata).unwrap();
let pyproject_toml = PyProjectToml::new(manifest_dir.join("pyproject.toml")).unwrap();
metadata
.merge_pyproject_toml(&manifest_dir, &pyproject_toml)
.unwrap();
assert_eq!(
metadata.summary,
Some("Implements a dummy function in Rust".to_string())
);
assert_eq!(
metadata.description,
Some(fs_err::read_to_string(manifest_dir.join("README.md")).unwrap())
);
assert_eq!(metadata.classifiers, &["Programming Language :: Rust"]);
assert_eq!(
metadata.maintainer_email,
Some("messense <messense@icloud.com>".to_string())
);
assert_eq!(metadata.scripts["get_42"], "pyo3_pure:DummyClass.get_42");
assert_eq!(
metadata.gui_scripts["get_42_gui"],
"pyo3_pure:DummyClass.get_42"
);
assert_eq!(metadata.provides_extra, &["test"]);
assert_eq!(
metadata.requires_dist,
&[
Requirement::from_str("attrs; extra == 'test'",).unwrap(),
Requirement::from_str("boltons; (sys_platform == 'win32') and extra == 'test'")
.unwrap(),
]
);
assert_eq!(metadata.license.as_ref().unwrap(), "MIT");
let license_file = &metadata.license_files[0];
assert_eq!(license_file.file_name().unwrap(), "LICENSE");
let content = metadata.to_file_contents().unwrap();
let pkginfo: Result<python_pkginfo::Metadata, _> = content.parse();
assert!(pkginfo.is_ok());
}
#[test]
fn test_merge_metadata_from_pyproject_toml_with_customized_python_source_dir() {
let manifest_dir = test_crate_path("pyo3-mixed-py-subdir");
let cargo_metadata = MetadataCommand::new()
.manifest_path(manifest_dir.join("Cargo.toml"))
.exec()
.unwrap();
let mut metadata = Metadata24::from_cargo_toml(&manifest_dir, &cargo_metadata).unwrap();
let pyproject_toml = PyProjectToml::new(manifest_dir.join("pyproject.toml")).unwrap();
metadata
.merge_pyproject_toml(&manifest_dir, &pyproject_toml)
.unwrap();
assert_eq!(
metadata.summary,
Some("Implements a dummy function combining rust and python".to_string())
);
assert_eq!(metadata.scripts["get_42"], "pyo3_mixed_py_subdir:get_42");
}
#[test]
fn test_implicit_readme() {
let manifest_dir = test_crate_path("pyo3-mixed");
let cargo_metadata = MetadataCommand::new()
.manifest_path(manifest_dir.join("Cargo.toml"))
.exec()
.unwrap();
let metadata = Metadata24::from_cargo_toml(&manifest_dir, &cargo_metadata).unwrap();
assert!(metadata.description.unwrap().starts_with("# pyo3-mixed"));
assert_eq!(
metadata.description_content_type.unwrap(),
"text/markdown; charset=UTF-8; variant=GFM"
);
}
#[test]
fn test_pep639() {
let manifest_dir = test_crate_path("pyo3-mixed");
let cargo_metadata = MetadataCommand::new()
.manifest_path(manifest_dir.join("Cargo.toml"))
.exec()
.unwrap();
let mut metadata = Metadata24::from_cargo_toml(&manifest_dir, &cargo_metadata).unwrap();
let pyproject_toml = PyProjectToml::new(manifest_dir.join("pyproject.toml")).unwrap();
metadata
.merge_pyproject_toml(&manifest_dir, &pyproject_toml)
.unwrap();
assert_eq!(metadata.license_expression.as_ref().unwrap(), "MIT");
assert_eq!(metadata.license.as_ref(), None);
}
#[test]
fn test_merge_metadata_from_pyproject_dynamic_license_test() {
let manifest_dir = test_crate_path("license-test");
let cargo_metadata = MetadataCommand::new()
.manifest_path(manifest_dir.join("Cargo.toml"))
.exec()
.unwrap();
let mut metadata = Metadata24::from_cargo_toml(&manifest_dir, &cargo_metadata).unwrap();
let pyproject_toml = PyProjectToml::new(manifest_dir.join("pyproject.toml")).unwrap();
metadata
.merge_pyproject_toml(&manifest_dir, &pyproject_toml)
.unwrap();
assert_eq!(metadata.license.as_ref().unwrap(), "MIT");
assert_eq!(4, metadata.license_files.len());
assert_eq!(metadata.license_files[0], PathBuf::from("LICENCE.txt"));
assert_eq!(metadata.license_files[1], PathBuf::from("LICENSE"));
assert_eq!(metadata.license_files[2], PathBuf::from("NOTICE.md"));
assert_eq!(metadata.license_files[3], PathBuf::from("AUTHORS.txt"));
}
#[test]
fn test_escape_email_with_display_name_without_special_characters() {
let display_name = "Foo Bar !#$%&'*+-/=?^_`{|}~ 123";
let email = "foobar-123@example.com";
let result = escape_email_with_display_name(display_name, email);
assert_eq!(
result,
"Foo Bar !#$%&'*+-/=?^_`{|}~ 123 <foobar-123@example.com>"
);
}
#[test]
fn test_escape_email_with_display_name_with_special_characters() {
let tests = [
("Foo ( Bar", "\"Foo ( Bar\""),
("Foo ) Bar", "\"Foo ) Bar\""),
("Foo < Bar", "\"Foo < Bar\""),
("Foo > Bar", "\"Foo > Bar\""),
("Foo @ Bar", "\"Foo @ Bar\""),
("Foo , Bar", "\"Foo , Bar\""),
("Foo ; Bar", "\"Foo ; Bar\""),
("Foo : Bar", "\"Foo : Bar\""),
("Foo \\ Bar", "\"Foo \\\\ Bar\""),
("Foo \" Bar", "\"Foo \\\" Bar\""),
("Foo . Bar", "\"Foo . Bar\""),
("Foo [ Bar", "\"Foo [ Bar\""),
("Foo ] Bar", "\"Foo ] Bar\""),
("Foo ) Bar", "\"Foo ) Bar\""),
("Foo ) Bar", "\"Foo ) Bar\""),
("Foo, Bar", "\"Foo, Bar\""),
];
for (display_name, expected_name) in tests {
let email = "foobar-123@example.com";
let result = escape_email_with_display_name(display_name, email);
let expected = format!("{expected_name} <{email}>");
assert_eq!(result, expected);
}
}
#[test]
fn test_license_file_normalization_outside_pyproject_dir() {
let temp_dir = TempDir::new().unwrap();
let workspace_root = temp_dir.path();
let crate_dir = workspace_root.join("crates/my-crate");
fs::create_dir_all(crate_dir.join("src")).unwrap();
fs::write(crate_dir.join("src/lib.rs"), "").unwrap();
fs::write(workspace_root.join("LICENSE"), "MIT License").unwrap();
let cargo_toml = indoc!(
r#"
[package]
name = "my-crate"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
"#
);
fs::write(crate_dir.join("Cargo.toml"), cargo_toml).unwrap();
let pyproject_toml_content = indoc!(
r#"
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[project]
name = "my-crate"
version = "0.1.0"
"#
);
fs::write(crate_dir.join("pyproject.toml"), pyproject_toml_content).unwrap();
let mut metadata =
Metadata24::new("my-crate".to_string(), Version::from_str("0.1.0").unwrap());
let abs_license = workspace_root
.join("LICENSE")
.normalize()
.unwrap()
.into_path_buf();
metadata.license_files.push(abs_license.clone());
let pyproject_toml = PyProjectToml::new(crate_dir.join("pyproject.toml")).unwrap();
metadata
.merge_pyproject_toml(&crate_dir, &pyproject_toml)
.unwrap();
assert!(
metadata
.license_files
.iter()
.any(|p| p == Path::new("LICENSE")),
"expected relative LICENSE in license_files, got: {:?}",
metadata.license_files
);
assert_eq!(
metadata.license_file_sources.get(Path::new("LICENSE")),
Some(&abs_license),
);
}
#[test]
fn test_license_normalization_without_project_table() {
let temp_dir = TempDir::new().unwrap();
let crate_dir = temp_dir.path();
fs::create_dir(crate_dir.join("src")).unwrap();
fs::write(crate_dir.join("src/lib.rs"), "").unwrap();
let cargo_toml = indoc!(
r#"
[package]
name = "no-project"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
"#
);
fs::write(crate_dir.join("Cargo.toml"), cargo_toml).unwrap();
let pyproject_toml_content = indoc!(
r#"
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
"#
);
fs::write(crate_dir.join("pyproject.toml"), pyproject_toml_content).unwrap();
fs::write(crate_dir.join("LICENSE"), "MIT").unwrap();
let mut metadata = Metadata24::new(
"no-project".to_string(),
Version::from_str("0.1.0").unwrap(),
);
let abs_license = crate_dir
.join("LICENSE")
.normalize()
.unwrap()
.into_path_buf();
metadata.license_files.push(abs_license);
let normalized_crate_dir = crate_dir.normalize().unwrap().into_path_buf();
let pyproject_toml = PyProjectToml::new(crate_dir.join("pyproject.toml")).unwrap();
metadata
.merge_pyproject_toml(&normalized_crate_dir, &pyproject_toml)
.unwrap();
assert!(
metadata.license_files.contains(&PathBuf::from("LICENSE")),
"expected relative LICENSE, got: {:?}",
metadata.license_files
);
assert!(
metadata.license_files.iter().all(|p| p.is_relative()),
"all license_files should be relative: {:?}",
metadata.license_files
);
}
#[test]
fn test_pyproject_license_file_overrides_cargo_source_mapping() {
let temp_dir = TempDir::new().unwrap();
let workspace_root = temp_dir.path();
let crate_dir = workspace_root.join("crate");
fs::create_dir_all(crate_dir.join("src")).unwrap();
fs::write(crate_dir.join("src/lib.rs"), "").unwrap();
fs::write(workspace_root.join("LICENSE"), "workspace license").unwrap();
fs::write(crate_dir.join("LICENSE"), "crate license").unwrap();
let pyproject_toml_content = indoc!(
r#"
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[project]
name = "my-crate"
version = "0.1.0"
license = { file = "LICENSE" }
"#
);
fs::write(crate_dir.join("pyproject.toml"), pyproject_toml_content).unwrap();
let mut metadata =
Metadata24::new("my-crate".to_string(), Version::from_str("0.1.0").unwrap());
metadata.license_files.push(PathBuf::from("LICENSE"));
metadata
.license_file_sources
.insert(PathBuf::from("LICENSE"), workspace_root.join("LICENSE"));
let pyproject_toml = PyProjectToml::new(crate_dir.join("pyproject.toml")).unwrap();
metadata
.merge_pyproject_toml(&crate_dir, &pyproject_toml)
.unwrap();
assert_eq!(
metadata
.license_files
.iter()
.filter(|path| path.as_path() == Path::new("LICENSE"))
.count(),
1,
"expected exactly one LICENSE entry, got {:?}",
metadata.license_files
);
assert!(
!metadata
.license_file_sources
.contains_key(Path::new("LICENSE")),
"expected pyproject license file to override Cargo source mapping"
);
let metadata_text = metadata.to_file_contents().unwrap();
assert_eq!(
metadata_text
.lines()
.filter(|line| *line == "License-File: LICENSE")
.count(),
1,
"expected a single License-File header, got:\n{metadata_text}"
);
}
#[test]
fn test_pyproject_license_files_override_cargo_source_mapping() {
let temp_dir = TempDir::new().unwrap();
let workspace_root = temp_dir.path();
let crate_dir = workspace_root.join("crate");
fs::create_dir_all(crate_dir.join("src")).unwrap();
fs::write(crate_dir.join("src/lib.rs"), "").unwrap();
fs::write(workspace_root.join("LICENSE"), "workspace license").unwrap();
fs::write(crate_dir.join("LICENSE"), "crate license").unwrap();
let pyproject_toml_content = indoc!(
r#"
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[project]
name = "my-crate"
version = "0.1.0"
license-files = ["LICENSE"]
"#
);
fs::write(crate_dir.join("pyproject.toml"), pyproject_toml_content).unwrap();
let mut metadata =
Metadata24::new("my-crate".to_string(), Version::from_str("0.1.0").unwrap());
metadata.license_files.push(PathBuf::from("LICENSE"));
metadata
.license_file_sources
.insert(PathBuf::from("LICENSE"), workspace_root.join("LICENSE"));
let pyproject_toml = PyProjectToml::new(crate_dir.join("pyproject.toml")).unwrap();
metadata
.merge_pyproject_toml(&crate_dir, &pyproject_toml)
.unwrap();
assert!(
!metadata
.license_file_sources
.contains_key(Path::new("LICENSE")),
"expected pyproject license-files to override Cargo source mapping"
);
assert_eq!(
metadata
.license_files
.iter()
.filter(|path| path.as_path() == Path::new("LICENSE"))
.count(),
1,
"expected exactly one LICENSE entry, got {:?}",
metadata.license_files
);
}
#[test]
fn test_from_cargo_toml_records_license_source_for_in_tree_license_file() {
let temp_dir = TempDir::new().unwrap();
let workspace_root = temp_dir.path();
let crate_dir = workspace_root.join("rust");
fs::create_dir_all(crate_dir.join("src")).unwrap();
fs::write(crate_dir.join("src/lib.rs"), "").unwrap();
fs::write(crate_dir.join("LICENSE"), "crate license").unwrap();
fs::write(workspace_root.join("LICENSE"), "workspace license").unwrap();
let cargo_toml = indoc!(
r#"
[package]
name = "my-crate"
version = "0.1.0"
edition = "2021"
license-file = "LICENSE"
[lib]
crate-type = ["cdylib"]
"#
);
fs::write(crate_dir.join("Cargo.toml"), cargo_toml).unwrap();
let cargo_metadata = MetadataCommand::new()
.manifest_path(crate_dir.join("Cargo.toml"))
.exec()
.unwrap();
let mut metadata = Metadata24::from_cargo_toml(&crate_dir, &cargo_metadata).unwrap();
assert_eq!(metadata.license_files, vec![PathBuf::from("LICENSE")]);
let expected_source = crate_dir
.join("LICENSE")
.normalize()
.unwrap()
.into_path_buf();
assert_eq!(
metadata.license_file_sources.get(Path::new("LICENSE")),
Some(&expected_source)
);
let pyproject_toml_content = indoc!(
r#"
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[project]
name = "my-crate"
version = "0.1.0"
"#
);
fs::write(
workspace_root.join("pyproject.toml"),
pyproject_toml_content,
)
.unwrap();
let pyproject_toml = PyProjectToml::new(workspace_root.join("pyproject.toml")).unwrap();
metadata
.merge_pyproject_toml(workspace_root, &pyproject_toml)
.unwrap();
assert_eq!(
metadata.license_file_sources.get(Path::new("LICENSE")),
Some(&expected_source)
);
}
#[test]
fn test_from_cargo_toml_rejects_license_file_outside_workspace_root() {
let temp_dir = TempDir::new().unwrap();
let workspace_root = temp_dir.path().join("workspace");
let crate_dir = workspace_root.join("crate");
let outside_license = temp_dir.path().join("SECRET_LICENSE");
fs::create_dir_all(crate_dir.join("src")).unwrap();
fs::write(crate_dir.join("src/lib.rs"), "").unwrap();
fs::write(&outside_license, "secret").unwrap();
fs::write(
workspace_root.join("Cargo.toml"),
indoc!(
r#"
[workspace]
resolver = "2"
members = ["crate"]
"#
),
)
.unwrap();
fs::write(
crate_dir.join("Cargo.toml"),
indoc!(
r#"
[package]
name = "crate"
version = "0.1.0"
edition = "2021"
license-file = "../../SECRET_LICENSE"
[lib]
crate-type = ["rlib"]
"#
),
)
.unwrap();
let cargo_metadata = MetadataCommand::new()
.manifest_path(crate_dir.join("Cargo.toml"))
.exec()
.unwrap();
let err = Metadata24::from_cargo_toml(&crate_dir, &cargo_metadata).unwrap_err();
assert!(
err.to_string().contains("outside Cargo workspace root"),
"unexpected error: {err:#}"
);
}
#[test]
fn test_reject_unsafe_project_license_file_path() {
let temp_dir = TempDir::new().unwrap();
let crate_dir = temp_dir.path();
let pyproject_toml_content = indoc!(
r#"
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[project]
name = "my-crate"
version = "0.1.0"
license = { file = "../LICENSE" }
"#
);
fs::write(crate_dir.join("pyproject.toml"), pyproject_toml_content).unwrap();
let pyproject_toml = PyProjectToml::new(crate_dir.join("pyproject.toml")).unwrap();
let mut metadata =
Metadata24::new("my-crate".to_string(), Version::from_str("0.1.0").unwrap());
let err = metadata
.merge_pyproject_toml(crate_dir, &pyproject_toml)
.unwrap_err();
assert!(
err.to_string()
.contains("`project.license.file` must be a safe relative path"),
"unexpected error: {err:#}"
);
}
#[test]
fn test_issue_2544_respect_pyproject_dynamic_without_dynamic_fields() {
let temp_dir = TempDir::new().unwrap();
let crate_path = temp_dir.path();
let manifest_path = crate_path.join("Cargo.toml");
let pyproject_path = crate_path.join("pyproject.toml");
fs::create_dir(crate_path.join("src")).unwrap();
fs::write(crate_path.join("src/lib.rs"), "").unwrap();
let cargo_toml = indoc!(
r#"
[package]
name = "test-package"
version = "0.1.0"
description = "Description from Cargo.toml - should not appear"
authors = ["author from cargo.toml <author@example.com>"]
keywords = ["cargo", "toml", "keyword"]
repository = "https://github.com/example/repo"
[lib]
crate-type = ["cdylib"]
"#
);
fs::write(&manifest_path, cargo_toml).unwrap();
let pyproject_toml_content = indoc!(
r#"
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[project]
name = "test-package"
version = "0.1.0"
# Note: no description, authors, keywords, urls in dynamic list
# dynamic = [] # Not specified, so defaults to empty
"#
);
fs::write(&pyproject_path, pyproject_toml_content).unwrap();
let cargo_metadata = MetadataCommand::new()
.manifest_path(&manifest_path)
.exec()
.unwrap();
let mut metadata = Metadata24::from_cargo_toml(crate_path, &cargo_metadata).unwrap();
let pyproject_toml = PyProjectToml::new(&pyproject_path).unwrap();
metadata
.merge_pyproject_toml(crate_path, &pyproject_toml)
.unwrap();
assert_eq!(
metadata.summary, None,
"summary should be None when not in dynamic list"
);
assert_eq!(
metadata.author, None,
"author should be None when not in dynamic list"
);
assert_eq!(
metadata.keywords, None,
"keywords should be None when not in dynamic list"
);
assert!(
metadata.project_url.is_empty(),
"project_url should be empty when not in dynamic list"
);
}
#[test]
fn test_issue_2544_respect_pyproject_dynamic_with_dynamic_fields() {
let temp_dir = TempDir::new().unwrap();
let crate_path = temp_dir.path();
let manifest_path = crate_path.join("Cargo.toml");
let pyproject_path = crate_path.join("pyproject.toml");
fs::create_dir(crate_path.join("src")).unwrap();
fs::write(crate_path.join("src/lib.rs"), "").unwrap();
let cargo_toml = indoc!(
r#"
[package]
name = "test-package"
version = "0.1.0"
description = "Description from Cargo.toml - should appear"
authors = ["author from cargo.toml <author@example.com>"]
keywords = ["cargo", "toml", "keyword"]
repository = "https://github.com/example/repo"
[lib]
crate-type = ["cdylib"]
"#
);
fs::write(&manifest_path, cargo_toml).unwrap();
let pyproject_toml_content = indoc!(
r#"
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[project]
name = "test-package"
version = "0.1.0"
# Fields declared as dynamic - should come from Cargo.toml
dynamic = ["description", "authors", "keywords", "urls"]
"#
);
fs::write(&pyproject_path, pyproject_toml_content).unwrap();
let cargo_metadata = MetadataCommand::new()
.manifest_path(&manifest_path)
.exec()
.unwrap();
let mut metadata = Metadata24::from_cargo_toml(crate_path, &cargo_metadata).unwrap();
let pyproject_toml = PyProjectToml::new(&pyproject_path).unwrap();
metadata
.merge_pyproject_toml(crate_path, &pyproject_toml)
.unwrap();
assert_eq!(
metadata.summary,
Some("Description from Cargo.toml - should appear".to_string())
);
assert_eq!(
metadata.author,
Some("author from cargo.toml <author@example.com>".to_string())
);
assert_eq!(metadata.keywords, Some("cargo,toml,keyword".to_string()));
assert_eq!(
metadata.project_url.get("Source Code"),
Some(&"https://github.com/example/repo".to_string())
);
}
}