use indexmap::IndexMap;
use pep440_rs::{Version, VersionSpecifiers};
use pep508_rs::Requirement;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub struct BuildSystem {
pub requires: Vec<Requirement>,
pub build_backend: Option<String>,
pub backend_path: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub struct PyProjectToml {
pub build_system: Option<BuildSystem>,
pub project: Option<Project>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub struct Project {
pub name: String,
pub version: Option<Version>,
pub description: Option<String>,
pub readme: Option<ReadMe>,
pub requires_python: Option<VersionSpecifiers>,
pub license: Option<License>,
pub license_files: Option<LicenseFiles>,
pub authors: Option<Vec<Contact>>,
pub maintainers: Option<Vec<Contact>>,
pub keywords: Option<Vec<String>>,
pub classifiers: Option<Vec<String>>,
pub urls: Option<IndexMap<String, String>>,
pub entry_points: Option<IndexMap<String, IndexMap<String, String>>>,
pub scripts: Option<IndexMap<String, String>>,
pub gui_scripts: Option<IndexMap<String, String>>,
pub dependencies: Option<Vec<Requirement>>,
pub optional_dependencies: Option<IndexMap<String, Vec<Requirement>>>,
pub dynamic: Option<Vec<String>>,
}
impl Project {
pub fn new(name: String) -> Self {
Self {
name,
version: None,
description: None,
readme: None,
requires_python: None,
license: None,
license_files: None,
authors: None,
maintainers: None,
keywords: None,
classifiers: None,
urls: None,
entry_points: None,
scripts: None,
gui_scripts: None,
dependencies: None,
optional_dependencies: None,
dynamic: None,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[serde(untagged)]
pub enum ReadMe {
RelativePath(String),
#[serde(rename_all = "kebab-case")]
Table {
file: Option<String>,
text: Option<String>,
content_type: Option<String>,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(untagged)]
pub enum License {
String(String),
Table {
file: Option<String>,
text: Option<String>,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub enum LicenseFiles {
#[serde(rename = "paths")]
Paths(Option<Vec<String>>),
#[serde(rename = "globs")]
Globs(Option<Vec<String>>),
}
impl Default for LicenseFiles {
fn default() -> Self {
LicenseFiles::Globs(Some(vec![
"LICEN[CS]E*".to_owned(),
"COPYING*".to_owned(),
"NOTICE*".to_owned(),
"AUTHORS*".to_owned(),
]))
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(expecting = "a table with 'name' and 'email' keys")]
pub struct Contact {
pub name: Option<String>,
pub email: Option<String>,
}
impl PyProjectToml {
pub fn new(content: &str) -> Result<Self, toml::de::Error> {
toml::de::from_str(content)
}
}
#[cfg(test)]
mod tests {
use super::{License, LicenseFiles, PyProjectToml, ReadMe};
use pep440_rs::{Version, VersionSpecifiers};
use pep508_rs::Requirement;
use std::str::FromStr;
#[test]
fn test_parse_pyproject_toml() {
let source = r#"[build-system]
requires = ["maturin"]
build-backend = "maturin"
[project]
name = "spam"
version = "2020.0.0"
description = "Lovely Spam! Wonderful Spam!"
readme = "README.rst"
requires-python = ">=3.8"
license = {file = "LICENSE.txt"}
keywords = ["egg", "bacon", "sausage", "tomatoes", "Lobster Thermidor"]
authors = [
{email = "hi@pradyunsg.me"},
{name = "Tzu-Ping Chung"}
]
maintainers = [
{name = "Brett Cannon", email = "brett@python.org"}
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python"
]
dependencies = [
"httpx",
"gidgethub[httpx]>4.0.0",
"django>2.1; os_name != 'nt'",
"django>2.0; os_name == 'nt'"
]
[project.optional-dependencies]
test = [
"pytest < 5.0.0",
"pytest-cov[all]"
]
[project.urls]
homepage = "example.com"
documentation = "readthedocs.org"
repository = "github.com"
changelog = "github.com/me/spam/blob/master/CHANGELOG.md"
[project.scripts]
spam-cli = "spam:main_cli"
[project.gui-scripts]
spam-gui = "spam:main_gui"
[project.entry-points."spam.magical"]
tomatoes = "spam:main_tomatoes""#;
let project_toml = PyProjectToml::new(source).unwrap();
let build_system = &project_toml.build_system.unwrap();
assert_eq!(
build_system.requires,
&[Requirement::from_str("maturin").unwrap()]
);
assert_eq!(build_system.build_backend.as_deref(), Some("maturin"));
let project = project_toml.project.as_ref().unwrap();
assert_eq!(project.name, "spam");
assert_eq!(
project.version,
Some(Version::from_str("2020.0.0").unwrap())
);
assert_eq!(
project.description.as_deref(),
Some("Lovely Spam! Wonderful Spam!")
);
assert_eq!(
project.readme,
Some(ReadMe::RelativePath("README.rst".to_string()))
);
assert_eq!(
project.requires_python,
Some(VersionSpecifiers::from_str(">=3.8").unwrap())
);
assert_eq!(
project.license,
Some(License::Table {
file: Some("LICENSE.txt".to_owned()),
text: None
})
);
assert_eq!(
project.keywords.as_ref().unwrap(),
&["egg", "bacon", "sausage", "tomatoes", "Lobster Thermidor"]
);
assert_eq!(
project.scripts.as_ref().unwrap()["spam-cli"],
"spam:main_cli"
);
assert_eq!(
project.gui_scripts.as_ref().unwrap()["spam-gui"],
"spam:main_gui"
);
}
#[test]
fn test_parse_pyproject_toml_license_expression() {
let source = r#"[build-system]
requires = ["maturin"]
build-backend = "maturin"
[project]
name = "spam"
license = "MIT OR BSD-3-Clause"
"#;
let project_toml = PyProjectToml::new(source).unwrap();
let project = project_toml.project.as_ref().unwrap();
assert_eq!(
project.license,
Some(License::String("MIT OR BSD-3-Clause".to_owned()))
);
}
#[test]
fn test_parse_pyproject_toml_license_paths() {
let source = r#"[build-system]
requires = ["maturin"]
build-backend = "maturin"
[project]
name = "spam"
license = "MIT AND (Apache-2.0 OR BSD-2-Clause)"
license-files.paths = [
"LICENSE",
"setuptools/_vendor/LICENSE",
"setuptools/_vendor/LICENSE.APACHE",
"setuptools/_vendor/LICENSE.BSD",
]
"#;
let project_toml = PyProjectToml::new(source).unwrap();
let project = project_toml.project.as_ref().unwrap();
assert_eq!(
project.license,
Some(License::String(
"MIT AND (Apache-2.0 OR BSD-2-Clause)".to_owned()
))
);
assert_eq!(
project.license_files,
Some(LicenseFiles::Paths(Some(vec![
"LICENSE".to_owned(),
"setuptools/_vendor/LICENSE".to_owned(),
"setuptools/_vendor/LICENSE.APACHE".to_owned(),
"setuptools/_vendor/LICENSE.BSD".to_owned()
])))
);
}
#[test]
fn test_parse_pyproject_toml_license_globs() {
let source = r#"[build-system]
requires = ["maturin"]
build-backend = "maturin"
[project]
name = "spam"
license = "MIT AND (Apache-2.0 OR BSD-2-Clause)"
license-files.globs = [
"LICENSE*",
"setuptools/_vendor/LICENSE*",
]
"#;
let project_toml = PyProjectToml::new(source).unwrap();
let project = project_toml.project.as_ref().unwrap();
assert_eq!(
project.license,
Some(License::String(
"MIT AND (Apache-2.0 OR BSD-2-Clause)".to_owned()
))
);
assert_eq!(
project.license_files,
Some(LicenseFiles::Globs(Some(vec![
"LICENSE*".to_owned(),
"setuptools/_vendor/LICENSE*".to_owned(),
])))
);
}
#[test]
fn test_parse_pyproject_toml_default_license_files() {
let source = r#"[build-system]
requires = ["maturin"]
build-backend = "maturin"
[project]
name = "spam"
"#;
let project_toml = PyProjectToml::new(source).unwrap();
let project = project_toml.project.as_ref().unwrap();
assert_eq!(
project.license_files.clone().unwrap_or_default(),
LicenseFiles::Globs(Some(vec![
"LICEN[CS]E*".to_owned(),
"COPYING*".to_owned(),
"NOTICE*".to_owned(),
"AUTHORS*".to_owned(),
]))
);
}
#[test]
fn test_parse_pyproject_toml_readme_content_type() {
let source = r#"[build-system]
requires = ["maturin"]
build-backend = "maturin"
[project]
name = "spam"
readme = {text = "ReadMe!", content-type = "text/plain"}
"#;
let project_toml = PyProjectToml::new(source).unwrap();
let project = project_toml.project.as_ref().unwrap();
assert_eq!(
project.readme,
Some(ReadMe::Table {
file: None,
text: Some("ReadMe!".to_string()),
content_type: Some("text/plain".to_string())
})
);
}
}