Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions manifest/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ cargo_toml = "1.0.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
strum = { version = "0.28.0", features = ["derive"] }
toml = "1.0.0"

[dev-dependencies]
rstest.workspace = true
90 changes: 90 additions & 0 deletions manifest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub struct Manifest {
pub enum ManifestType {
Npm,
Cargo,
PyProject,
}

pub fn get_manifests<P: AsRef<Path>>(path: P) -> Result<Vec<Manifest>> {
Expand All @@ -36,6 +37,7 @@ pub fn get_manifests<P: AsRef<Path>>(path: P) -> Result<Vec<Manifest>> {
.filter_map(|(file_path, manifest_type)| match manifest_type {
ManifestType::Cargo => parse_cargo_manifest(&file_path).ok(),
ManifestType::Npm => parse_npm_manifest(&file_path).ok(),
ManifestType::PyProject => parse_pyproject_manifest(&file_path).ok(),
})
.collect::<Vec<_>>();

Expand Down Expand Up @@ -85,10 +87,98 @@ fn parse_npm_manifest(path: &Path) -> Result<Manifest> {
})
}

/// A PEP 621 `pyproject.toml` `[project]` table.
///
/// Only the fields that map onto [`Manifest`] are deserialized. Tool-specific
/// tables such as `[tool.poetry]` use a different layout and are not handled here.
#[derive(Deserialize)]
struct PyProject {
project: Option<PyProjectTable>,
}

#[derive(Deserialize)]
struct PyProjectTable {
name: Option<String>,
version: Option<String>,
description: Option<String>,
license: Option<PyProjectLicense>,
#[serde(default)]
dependencies: Vec<String>,
}

/// PEP 621 allows `license` to be either an SPDX expression string (PEP 639) or
/// a table with a `text` or `file` key.
///
/// - `Spdx` is the PEP 639 form, e.g. `license = "MIT"`.
/// - `Table.text` is the older PEP 621 form, e.g. `license = { text = "MIT" }`.
/// Its value is free-form: it is conventionally an SPDX identifier, but the
/// spec permits any string (for example the full text of a non-standard
/// license). We surface it verbatim and let the caller decide how to render
/// it, rather than trying to validate it as SPDX.
/// - `license = { file = "LICENSE" }` points at a file and carries no
/// identifier, so it deserializes to `text: None` (the `file` key is ignored).
#[derive(Deserialize)]
#[serde(untagged)]
enum PyProjectLicense {
Spdx(String),
Table { text: Option<String> },
}

fn parse_pyproject_manifest(path: &Path) -> Result<Manifest> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read pyproject.toml at '{}'", path.display()))?;

let pyproject: PyProject = toml::from_str(&content)
.with_context(|| format!("Failed to parse pyproject.toml at '{}'", path.display()))?;

let project = pyproject
.project
.context("pyproject.toml has no [project] table")?;

// The SPDX string and a `{ text = "..." }` table both carry a license
// identifier (usually SPDX, though `text` may be any free-form string), so
// use them directly. A `{ file = "LICENSE" }` table has no identifier, so
// leave it unset and let onefetch fall back to detecting the license from
// the repo.
let license = project.license.and_then(|license| match license {
PyProjectLicense::Spdx(spdx) => Some(spdx),
PyProjectLicense::Table { text } => text,
});

Ok(Manifest {
manifest_type: ManifestType::PyProject,
number_of_dependencies: project.dependencies.len(),
name: project.name,
description: project.description,
version: project.version,
license,
})
}

fn file_name_to_manifest_type(filename: &str) -> Option<ManifestType> {
match filename {
"Cargo.toml" => Some(ManifestType::Cargo),
"package.json" => Some(ManifestType::Npm),
"pyproject.toml" => Some(ManifestType::PyProject),
_ => None,
}
}

#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;

#[rstest]
#[case::pep_639("license = \"MIT\"", Some("MIT"))]
#[case::pep_621_text("license = { text = \"Apache-2.0\" }", Some("Apache-2.0"))]
#[case::pep_621_file("license = { file = \"LICENSE\" }", None)]
fn parses_pep621_license_forms(#[case] source: &str, #[case] expected: Option<&str>) {
let table: PyProjectTable = toml::from_str(source).unwrap();
let license = table.license.and_then(|license| match license {
PyProjectLicense::Spdx(spdx) => Some(spdx),
PyProjectLicense::Table { text } => text,
});
assert_eq!(license.as_deref(), expected);
}
}
17 changes: 17 additions & 0 deletions manifest/tests/fixtures/pyproject/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "my_package"
version = "1.0.0"
description = "description for my_package"
license = "MIT"
dependencies = [
"requests>=2.31",
"click>=8.1",
"rich>=13.0",
]

[dependency-groups]
dev = ["pytest", "ruff"]
19 changes: 19 additions & 0 deletions manifest/tests/pyproject.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
use anyhow::Result;
use onefetch_manifest::{ManifestType, get_manifests};

#[test]
fn should_detect_and_parse_pyproject_manifest() -> Result<()> {
let manifests = get_manifests("tests/fixtures/pyproject")?;
assert_eq!(manifests.len(), 1);
let pyproject_manifest = manifests.first().unwrap();
assert_eq!(pyproject_manifest.manifest_type, ManifestType::PyProject);
assert_eq!(pyproject_manifest.number_of_dependencies, 3);
assert_eq!(pyproject_manifest.name, Some(String::from("my_package")));
assert_eq!(
pyproject_manifest.description,
Some("description for my_package".into())
);
assert_eq!(pyproject_manifest.version, Some(String::from("1.0.0")));
assert_eq!(pyproject_manifest.license, Some("MIT".into()));
Ok(())
}
Loading