Skip to main content

package_parser/pkgs/python/
pyproject.rs

1//! Parser for `pyproject.toml` files.
2
3use std::{collections::HashMap, path::Path};
4
5use crate::types::Package;
6use serde::Deserialize;
7
8use crate::{error::SourcePkgError, pkgs::python::poetrylock, PackageManifest};
9
10lazy_static::lazy_static! {
11    static ref NORMALIZE_NAME: regex::Regex = regex::Regex::new(r"[-_.]+").unwrap();
12}
13
14#[derive(Debug, Deserialize, Default)]
15#[serde(default)]
16pub struct ProjectSpec {
17    pub tool: ProjectSpecTool,
18}
19
20#[derive(Debug, Deserialize, Default)]
21#[serde(default)]
22pub struct ProjectSpecTool {
23    pub poetry: Option<PoetryTool>,
24}
25
26#[derive(Debug, Deserialize, Default)]
27#[serde(default, rename_all = "kebab-case")]
28pub struct PoetryTool {
29    dependencies: HashMap<String, PoetryDependency>,
30    /// Poetry pre-1.2.x style, understood by Poetry 1.0–1.2
31    dev_dependencies: HashMap<String, PoetryDependency>,
32    group: HashMap<String, PoetryGroup>,
33}
34
35impl PoetryTool {
36    pub fn has_dependency(&self, name: &str) -> bool {
37        if self.dependencies.contains_key(name) {
38            return true;
39        }
40
41        if self.dev_dependencies.contains_key(name) {
42            return true;
43        }
44
45        for group in self.group.values() {
46            if group.dependencies.contains_key(name) {
47                return true;
48            }
49        }
50
51        false
52    }
53}
54
55#[derive(Debug, Deserialize, Default)]
56#[serde(default)]
57struct PoetryGroup {
58    dependencies: HashMap<String, PoetryDependency>,
59}
60
61#[derive(Debug, Deserialize)]
62#[serde(untagged)]
63#[allow(unused)]
64enum PoetryDependency {
65    Compact(String),
66    Expanded(PoetryExpandedDependency),
67    List(Vec<PoetryExpandedDependency>),
68}
69
70#[allow(dead_code)]
71#[derive(Debug, Deserialize)]
72struct PoetryExpandedDependency {
73    version: Option<String>,
74    markers: Option<String>,
75}
76
77pub struct PyProject {}
78
79impl PyProject {
80    pub fn new() -> Self {
81        Self {}
82    }
83
84    fn parse(path: &Path) -> Result<Package, SourcePkgError> {
85        let path_dir = path.parent().unwrap();
86
87        let manifest_content = std::fs::read_to_string(path)?;
88        let manifest: ProjectSpec = toml::from_str(&manifest_content)?;
89
90        if let Some(mut poetry) = manifest.tool.poetry {
91            // Normalize package names
92            let normalize_name =
93                |name: &str| -> String { NORMALIZE_NAME.replace_all(name, "-").to_lowercase() };
94
95            let deps = poetry
96                .dependencies
97                .drain()
98                .map(|(name, dep)| (normalize_name(&name), dep))
99                .collect();
100            poetry.dependencies = deps;
101
102            let dev_deps = poetry
103                .dev_dependencies
104                .drain()
105                .map(|(name, dep)| (normalize_name(&name), dep))
106                .collect();
107            poetry.dev_dependencies = dev_deps;
108
109            for group in poetry.group.values_mut() {
110                let deps = group
111                    .dependencies
112                    .drain()
113                    .map(|(name, dep)| (normalize_name(&name), dep))
114                    .collect();
115                group.dependencies = deps;
116            }
117
118            // Resolve with lock file
119            let lock_path = path_dir.join("poetry.lock");
120            if lock_path.exists() {
121                return poetrylock::process(&lock_path, &poetry);
122            }
123        }
124
125        Ok(Default::default())
126    }
127}
128
129#[async_trait::async_trait]
130impl PackageManifest for PyProject {
131    fn get_name(&self) -> String {
132        "pypi".to_string()
133    }
134
135    async fn recognize(&self, path: &Path) -> Result<Package, SourcePkgError> {
136        Self::parse(path)
137    }
138
139    fn file_name_patterns(&self) -> &'static [&'static str] {
140        &["pyproject.toml"]
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn test_poetry_with_lock() {
150        let filepath = Path::new(concat!(
151            env!("CARGO_MANIFEST_DIR"),
152            "/testdata/pypi/poetry/pyproject.toml"
153        ));
154
155        let p = PyProject::parse(filepath).unwrap();
156        println!("{:?}", p);
157    }
158}