pyproject_toml/
lib.rs

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