pyproject_toml/
lib.rs

1#[cfg(feature = "pep639-glob")]
2mod pep639_glob;
3
4#[cfg(feature = "pep639-glob")]
5pub use pep639_glob::{check_pep639_glob, parse_pep639_glob, Pep639GlobError};
6
7pub mod pep735_resolve;
8
9use indexmap::IndexMap;
10use pep440_rs::{Version, VersionSpecifiers};
11use pep508_rs::Requirement;
12use serde::{Deserialize, Serialize};
13use std::ops::Deref;
14use std::path::PathBuf;
15
16/// The `[build-system]` section of a pyproject.toml as specified in PEP 517
17#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
18#[serde(rename_all = "kebab-case")]
19pub struct BuildSystem {
20    /// PEP 508 dependencies required to execute the build system
21    pub requires: Vec<Requirement>,
22    /// A string naming a Python object that will be used to perform the build
23    pub build_backend: Option<String>,
24    /// Specify that their backend code is hosted in-tree, this key contains a list of directories
25    pub backend_path: Option<Vec<String>>,
26}
27
28/// A pyproject.toml as specified in PEP 517
29#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
30#[serde(rename_all = "kebab-case")]
31pub struct PyProjectToml {
32    /// Build-related data
33    pub build_system: Option<BuildSystem>,
34    /// Project metadata
35    pub project: Option<Project>,
36    /// Dependency groups table
37    pub dependency_groups: Option<DependencyGroups>,
38}
39
40/// PEP 621 project metadata
41#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
42#[serde(rename_all = "kebab-case")]
43pub struct Project {
44    /// The name of the project
45    pub name: String,
46    /// The version of the project as supported by PEP 440
47    pub version: Option<Version>,
48    /// The summary description of the project
49    pub description: Option<String>,
50    /// The full description of the project (i.e. the README)
51    pub readme: Option<ReadMe>,
52    /// The Python version requirements of the project
53    pub requires_python: Option<VersionSpecifiers>,
54    /// The license under which the project is distributed
55    ///
56    /// Supports both the current standard and the provisional PEP 639
57    pub license: Option<License>,
58    /// The paths to files containing licenses and other legal notices to be distributed with the
59    /// project.
60    ///
61    /// Use `parse_pep639_glob` from the optional `pep639-glob` feature to find the matching files.
62    ///
63    /// Note that this doesn't check the PEP 639 rules for combining `license_files` and `license`.
64    ///
65    /// From the provisional PEP 639
66    pub license_files: Option<Vec<String>>,
67    /// The people or organizations considered to be the "authors" of the project
68    pub authors: Option<Vec<Contact>>,
69    /// Similar to "authors" in that its exact meaning is open to interpretation
70    pub maintainers: Option<Vec<Contact>>,
71    /// The keywords for the project
72    pub keywords: Option<Vec<String>>,
73    /// Trove classifiers which apply to the project
74    pub classifiers: Option<Vec<String>>,
75    /// A table of URLs where the key is the URL label and the value is the URL itself
76    pub urls: Option<IndexMap<String, String>>,
77    /// Entry points
78    pub entry_points: Option<IndexMap<String, IndexMap<String, String>>>,
79    /// Corresponds to the console_scripts group in the core metadata
80    pub scripts: Option<IndexMap<String, String>>,
81    /// Corresponds to the gui_scripts group in the core metadata
82    pub gui_scripts: Option<IndexMap<String, String>>,
83    /// Project dependencies
84    pub dependencies: Option<Vec<Requirement>>,
85    /// Optional dependencies
86    pub optional_dependencies: Option<IndexMap<String, Vec<Requirement>>>,
87    /// Specifies which fields listed by PEP 621 were intentionally unspecified
88    /// so another tool can/will provide such metadata dynamically.
89    pub dynamic: Option<Vec<String>>,
90}
91
92impl Project {
93    /// Initializes the only field mandatory in PEP 621 (`name`) and leaves everything else empty
94    pub fn new(name: String) -> Self {
95        Self {
96            name,
97            version: None,
98            description: None,
99            readme: None,
100            requires_python: None,
101            license: None,
102            license_files: None,
103            authors: None,
104            maintainers: None,
105            keywords: None,
106            classifiers: None,
107            urls: None,
108            entry_points: None,
109            scripts: None,
110            gui_scripts: None,
111            dependencies: None,
112            optional_dependencies: None,
113            dynamic: None,
114        }
115    }
116}
117
118/// The full description of the project (i.e. the README).
119#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
120#[serde(rename_all = "kebab-case")]
121#[serde(untagged)]
122pub enum ReadMe {
123    /// Relative path to a text file containing the full description
124    RelativePath(String),
125    /// Detailed readme information
126    #[serde(rename_all = "kebab-case")]
127    Table {
128        /// A relative path to a file containing the full description
129        file: Option<String>,
130        /// Full description
131        text: Option<String>,
132        /// The content-type of the full description
133        content_type: Option<String>,
134    },
135}
136
137/// The optional `project.license` key
138///
139/// Specified in <https://packaging.python.org/en/latest/specifications/pyproject-toml/#license>.
140#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
141#[serde(untagged)]
142pub enum License {
143    /// An SPDX Expression.
144    ///
145    /// Note that this doesn't check the validity of the SPDX expression or PEP 639 rules.
146    ///
147    /// From the provisional PEP 639.
148    Spdx(String),
149    Text {
150        /// The full text of the license.
151        text: String,
152    },
153    File {
154        /// The file containing the license text.
155        file: PathBuf,
156    },
157}
158
159/// A `project.authors` or `project.maintainers` entry.
160///
161/// Specified in
162/// <https://packaging.python.org/en/latest/specifications/pyproject-toml/#authors-maintainers>.
163///
164/// The entry is derived from the email format of `John Doe <john.doe@example.net>`. You need to
165/// provide at least name or email.
166#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
167// deny_unknown_fields prevents using the name field when the email is not a string.
168#[serde(
169    untagged,
170    deny_unknown_fields,
171    expecting = "a table with 'name' and/or 'email' keys"
172)]
173pub enum Contact {
174    /// TODO(konsti): RFC 822 validation.
175    NameEmail { name: String, email: String },
176    /// TODO(konsti): RFC 822 validation.
177    Name { name: String },
178    /// TODO(konsti): RFC 822 validation.
179    Email { email: String },
180}
181
182impl Contact {
183    /// Returns the name of the contact.
184    pub fn name(&self) -> Option<&str> {
185        match self {
186            Contact::NameEmail { name, .. } | Contact::Name { name } => Some(name),
187            Contact::Email { .. } => None,
188        }
189    }
190
191    /// Returns the email of the contact.
192    pub fn email(&self) -> Option<&str> {
193        match self {
194            Contact::NameEmail { email, .. } | Contact::Email { email } => Some(email),
195            Contact::Name { .. } => None,
196        }
197    }
198}
199
200/// The `[dependency-groups]` section of pyproject.toml, as specified in PEP 735
201#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
202#[serde(transparent)]
203pub struct DependencyGroups(pub IndexMap<String, Vec<DependencyGroupSpecifier>>);
204
205impl Deref for DependencyGroups {
206    type Target = IndexMap<String, Vec<DependencyGroupSpecifier>>;
207
208    fn deref(&self) -> &Self::Target {
209        &self.0
210    }
211}
212
213/// A specifier item in a Dependency Group
214#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
215#[serde(rename_all = "kebab-case", untagged)]
216#[allow(clippy::large_enum_variant)]
217pub enum DependencyGroupSpecifier {
218    /// PEP 508 requirement string
219    String(Requirement),
220    /// Include another dependency group
221    #[serde(rename_all = "kebab-case")]
222    Table {
223        /// The name of the group to include
224        include_group: String,
225    },
226}
227
228impl PyProjectToml {
229    /// Parse `pyproject.toml` content
230    pub fn new(content: &str) -> Result<Self, toml::de::Error> {
231        toml::de::from_str(content)
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::{DependencyGroupSpecifier, License, PyProjectToml, ReadMe};
238    use pep440_rs::{Version, VersionSpecifiers};
239    use pep508_rs::Requirement;
240    use std::path::PathBuf;
241    use std::str::FromStr;
242
243    #[test]
244    fn test_parse_pyproject_toml() {
245        let source = r#"[build-system]
246requires = ["maturin"]
247build-backend = "maturin"
248
249[project]
250name = "spam"
251version = "2020.0.0"
252description = "Lovely Spam! Wonderful Spam!"
253readme = "README.rst"
254requires-python = ">=3.8"
255license = {file = "LICENSE.txt"}
256keywords = ["egg", "bacon", "sausage", "tomatoes", "Lobster Thermidor"]
257authors = [
258  {email = "hi@pradyunsg.me"},
259  {name = "Tzu-Ping Chung"}
260]
261maintainers = [
262  {name = "Brett Cannon", email = "brett@python.org"}
263]
264classifiers = [
265  "Development Status :: 4 - Beta",
266  "Programming Language :: Python"
267]
268
269dependencies = [
270  "httpx",
271  "gidgethub[httpx]>4.0.0",
272  "django>2.1; os_name != 'nt'",
273  "django>2.0; os_name == 'nt'"
274]
275
276[project.optional-dependencies]
277test = [
278  "pytest < 5.0.0",
279  "pytest-cov[all]"
280]
281
282[project.urls]
283homepage = "example.com"
284documentation = "readthedocs.org"
285repository = "github.com"
286changelog = "github.com/me/spam/blob/master/CHANGELOG.md"
287
288[project.scripts]
289spam-cli = "spam:main_cli"
290
291[project.gui-scripts]
292spam-gui = "spam:main_gui"
293
294[project.entry-points."spam.magical"]
295tomatoes = "spam:main_tomatoes""#;
296        let project_toml = PyProjectToml::new(source).unwrap();
297        let build_system = &project_toml.build_system.unwrap();
298        assert_eq!(
299            build_system.requires,
300            &[Requirement::from_str("maturin").unwrap()]
301        );
302        assert_eq!(build_system.build_backend.as_deref(), Some("maturin"));
303
304        let project = project_toml.project.as_ref().unwrap();
305        assert_eq!(project.name, "spam");
306        assert_eq!(
307            project.version,
308            Some(Version::from_str("2020.0.0").unwrap())
309        );
310        assert_eq!(
311            project.description.as_deref(),
312            Some("Lovely Spam! Wonderful Spam!")
313        );
314        assert_eq!(
315            project.readme,
316            Some(ReadMe::RelativePath("README.rst".to_string()))
317        );
318        assert_eq!(
319            project.requires_python,
320            Some(VersionSpecifiers::from_str(">=3.8").unwrap())
321        );
322        assert_eq!(
323            project.license,
324            Some(License::File {
325                file: PathBuf::from("LICENSE.txt"),
326            })
327        );
328        assert_eq!(
329            project.keywords.as_ref().unwrap(),
330            &["egg", "bacon", "sausage", "tomatoes", "Lobster Thermidor"]
331        );
332        assert_eq!(
333            project.scripts.as_ref().unwrap()["spam-cli"],
334            "spam:main_cli"
335        );
336        assert_eq!(
337            project.gui_scripts.as_ref().unwrap()["spam-gui"],
338            "spam:main_gui"
339        );
340    }
341
342    #[test]
343    fn test_parse_pyproject_toml_license_expression() {
344        let source = r#"[build-system]
345requires = ["maturin"]
346build-backend = "maturin"
347
348[project]
349name = "spam"
350license = "MIT OR BSD-3-Clause"
351"#;
352        let project_toml = PyProjectToml::new(source).unwrap();
353        let project = project_toml.project.as_ref().unwrap();
354        assert_eq!(
355            project.license,
356            Some(License::Spdx("MIT OR BSD-3-Clause".to_owned()))
357        );
358    }
359
360    /// https://peps.python.org/pep-0639/
361    #[test]
362    fn test_parse_pyproject_toml_license_paths() {
363        let source = r#"[build-system]
364requires = ["maturin"]
365build-backend = "maturin"
366
367[project]
368name = "spam"
369license = "MIT AND (Apache-2.0 OR BSD-2-Clause)"
370license-files = [
371    "LICENSE",
372    "setuptools/_vendor/LICENSE",
373    "setuptools/_vendor/LICENSE.APACHE",
374    "setuptools/_vendor/LICENSE.BSD",
375]
376"#;
377        let project_toml = PyProjectToml::new(source).unwrap();
378        let project = project_toml.project.as_ref().unwrap();
379
380        assert_eq!(
381            project.license,
382            Some(License::Spdx(
383                "MIT AND (Apache-2.0 OR BSD-2-Clause)".to_owned()
384            ))
385        );
386        assert_eq!(
387            project.license_files,
388            Some(vec![
389                "LICENSE".to_owned(),
390                "setuptools/_vendor/LICENSE".to_owned(),
391                "setuptools/_vendor/LICENSE.APACHE".to_owned(),
392                "setuptools/_vendor/LICENSE.BSD".to_owned()
393            ])
394        );
395    }
396
397    // https://peps.python.org/pep-0639/
398    #[test]
399    fn test_parse_pyproject_toml_license_globs() {
400        let source = r#"[build-system]
401requires = ["maturin"]
402build-backend = "maturin"
403
404[project]
405name = "spam"
406license = "MIT AND (Apache-2.0 OR BSD-2-Clause)"
407license-files = [
408    "LICENSE*",
409    "setuptools/_vendor/LICENSE*",
410]
411"#;
412        let project_toml = PyProjectToml::new(source).unwrap();
413        let project = project_toml.project.as_ref().unwrap();
414
415        assert_eq!(
416            project.license,
417            Some(License::Spdx(
418                "MIT AND (Apache-2.0 OR BSD-2-Clause)".to_owned()
419            ))
420        );
421        assert_eq!(
422            project.license_files,
423            Some(vec![
424                "LICENSE*".to_owned(),
425                "setuptools/_vendor/LICENSE*".to_owned(),
426            ])
427        );
428    }
429
430    #[test]
431    fn test_parse_pyproject_toml_default_license_files() {
432        let source = r#"[build-system]
433requires = ["maturin"]
434build-backend = "maturin"
435
436[project]
437name = "spam"
438"#;
439        let project_toml = PyProjectToml::new(source).unwrap();
440        let project = project_toml.project.as_ref().unwrap();
441
442        // Changed from the PEP 639 draft.
443        assert_eq!(project.license_files.clone(), None);
444    }
445
446    #[test]
447    fn test_parse_pyproject_toml_readme_content_type() {
448        let source = r#"[build-system]
449requires = ["maturin"]
450build-backend = "maturin"
451
452[project]
453name = "spam"
454readme = {text = "ReadMe!", content-type = "text/plain"}
455"#;
456        let project_toml = PyProjectToml::new(source).unwrap();
457        let project = project_toml.project.as_ref().unwrap();
458
459        assert_eq!(
460            project.readme,
461            Some(ReadMe::Table {
462                file: None,
463                text: Some("ReadMe!".to_string()),
464                content_type: Some("text/plain".to_string())
465            })
466        );
467    }
468
469    #[test]
470    fn test_parse_pyproject_toml_dependency_groups() {
471        let source = r#"[dependency-groups]
472alpha = ["beta", "gamma", "delta"]
473epsilon = ["eta<2.0", "theta==2024.09.01"]
474iota = [{include-group = "alpha"}]
475"#;
476        let project_toml = PyProjectToml::new(source).unwrap();
477        let dependency_groups = project_toml.dependency_groups.as_ref().unwrap();
478
479        assert_eq!(
480            dependency_groups["alpha"],
481            vec![
482                DependencyGroupSpecifier::String(Requirement::from_str("beta").unwrap()),
483                DependencyGroupSpecifier::String(Requirement::from_str("gamma").unwrap()),
484                DependencyGroupSpecifier::String(Requirement::from_str("delta").unwrap(),)
485            ]
486        );
487        assert_eq!(
488            dependency_groups["epsilon"],
489            vec![
490                DependencyGroupSpecifier::String(Requirement::from_str("eta<2.0").unwrap()),
491                DependencyGroupSpecifier::String(
492                    Requirement::from_str("theta==2024.09.01").unwrap()
493                )
494            ]
495        );
496        assert_eq!(
497            dependency_groups["iota"],
498            vec![DependencyGroupSpecifier::Table {
499                include_group: "alpha".to_string()
500            }]
501        );
502    }
503
504    #[test]
505    fn invalid_email() {
506        let source = r#"
507[project]
508name = "hello-world"
509version = "0.1.0"
510# Ensure that the spans from toml handle utf-8 correctly
511authors = [
512    { name = "Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘", email = 1 }
513]
514"#;
515        let err = PyProjectToml::new(source).unwrap_err();
516        assert_eq!(
517            err.to_string(),
518            "TOML parse error at line 6, column 11
519  |
5206 | authors = [
521  |           ^
522a table with 'name' and/or 'email' keys
523"
524        );
525    }
526
527    #[test]
528    fn test_contact_accessors() {
529        let contact = super::Contact::NameEmail {
530            name: "John Doe".to_string(),
531            email: "john@example.com".to_string(),
532        };
533
534        assert_eq!(contact.name(), Some("John Doe"));
535        assert_eq!(contact.email(), Some("john@example.com"));
536
537        let contact = super::Contact::Name {
538            name: "John Doe".to_string(),
539        };
540
541        assert_eq!(contact.name(), Some("John Doe"));
542        assert_eq!(contact.email(), None);
543
544        let contact = super::Contact::Email {
545            email: "john@example.com".to_string(),
546        };
547
548        assert_eq!(contact.name(), None);
549        assert_eq!(contact.email(), Some("john@example.com"));
550    }
551}