python_packaging/
package_metadata.rs

1// Copyright 2022 Gregory Szorc.
2//
3// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4// https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
5// <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
6// option. This file may not be copied, modified, or distributed
7// except according to those terms.
8
9/*! Working with Python package metadata (i.e. .pkg-info directories) */
10
11use {
12    anyhow::{Context, Result},
13    mailparse::parse_mail,
14};
15
16/// Represents a Python METADATA file.
17pub struct PythonPackageMetadata {
18    headers: Vec<(String, String)>,
19}
20
21impl PythonPackageMetadata {
22    /// Create an instance from data in a METADATA file.
23    pub fn from_metadata(data: &[u8]) -> Result<PythonPackageMetadata> {
24        let message = parse_mail(data).context("parsing metadata file")?;
25
26        let headers = message
27            .headers
28            .iter()
29            .map(|header| (header.get_key(), header.get_value()))
30            .collect::<Vec<_>>();
31
32        Ok(PythonPackageMetadata { headers })
33    }
34
35    /// Find the first value of a specified header.
36    pub fn find_first_header(&self, key: &str) -> Option<&str> {
37        for (k, v) in &self.headers {
38            if k == key {
39                return Some(v);
40            }
41        }
42
43        None
44    }
45
46    /// Find all values of a specified header.
47    #[allow(unused)]
48    pub fn find_all_headers(&self, key: &str) -> Vec<&str> {
49        self.headers
50            .iter()
51            .filter_map(|(k, v)| if k == key { Some(v.as_ref()) } else { None })
52            .collect::<Vec<_>>()
53    }
54
55    pub fn name(&self) -> Option<&str> {
56        self.find_first_header("Name")
57    }
58
59    pub fn version(&self) -> Option<&str> {
60        self.find_first_header("Version")
61    }
62
63    #[allow(unused)]
64    pub fn license(&self) -> Option<&str> {
65        self.find_first_header("License")
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn test_parse_metadata() -> Result<()> {
75        let data = concat!(
76            "Metadata-Version: 2.1\n",
77            "Name: black\n",
78            "Version: 19.10b0\n",
79            "Summary: The uncompromising code formatter.\n",
80            "Home-page: https://github.com/psf/black\n",
81            "Author: Ɓukasz Langa\n",
82            "Author-email: lukasz@langa.pl\n",
83            "License: MIT\n",
84            "Requires-Dist: click (>=6.5)\n",
85            "Requires-Dist: attrs (>=18.1.0)\n",
86            "Requires-Dist: appdirs\n",
87            "\n",
88            "![Black Logo](https://raw.githubusercontent.com/psf/black/master/docs/_static/logo2-readme.png)\n",
89            "\n",
90            "<h2 align=\"center\">The Uncompromising Code Formatter</h2>\n",
91        ).as_bytes();
92
93        let m = PythonPackageMetadata::from_metadata(data)?;
94
95        assert_eq!(m.name(), Some("black"));
96        assert_eq!(m.version(), Some("19.10b0"));
97        assert_eq!(m.license(), Some("MIT"));
98        assert_eq!(
99            m.find_all_headers("Requires-Dist"),
100            vec!["click (>=6.5)", "attrs (>=18.1.0)", "appdirs"]
101        );
102        assert_eq!(m.find_first_header("Missing"), None);
103
104        Ok(())
105    }
106}