pyproject-toml-0.13.4/.cargo_vcs_info.json0000644000000001360000000000100141110ustar { "git": { "sha1": "2ed4df20c605e8f3388d71873ff54e140837a659" }, "path_in_vcs": "" }pyproject-toml-0.13.4/.github/dependabot.yml000064400000000000000000000006721046102023000170760ustar 00000000000000# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" pyproject-toml-0.13.4/.github/workflows/CI.yml000064400000000000000000000023051046102023000173140ustar 00000000000000on: push: branches: - main pull_request: name: CI jobs: lint-rust: name: Lint Rust runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: Swatinem/rust-cache@v2 - run: rustup component add clippy rustfmt - name: Rustfmt run: cargo fmt --all -- --check - name: Clippy env: RUSTFLAGS: -D warnings run: cargo clippy --workspace - name: Clippy (all features) env: RUSTFLAGS: -D warnings run: cargo clippy --workspace --all-features - name: Check documentation env: RUSTDOCFLAGS: -D warnings run: cargo doc --workspace --all-features --no-deps --document-private-items test: name: Test Suite runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: Swatinem/rust-cache@v2 - run: cargo test check-wasm: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: Swatinem/rust-cache@v2 - run: rustup target add wasm32-unknown-unknown - run: cargo check --target wasm32-unknown-unknown pyproject-toml-0.13.4/.github/workflows/release.yml000064400000000000000000000005051046102023000204410ustar 00000000000000name: Release on: push: tags: - v* jobs: crates-io: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - name: Push to crates.io env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: cargo publish pyproject-toml-0.13.4/.gitignore000064400000000000000000000000101046102023000146600ustar 00000000000000/target pyproject-toml-0.13.4/Cargo.toml0000644000000027060000000000100121140ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" rust-version = "1.64" name = "pyproject-toml" version = "0.13.4" build = false autobins = false autoexamples = false autotests = false autobenches = false description = "pyproject.toml parser in Rust" readme = "README.md" keywords = [ "pyproject", "pep517", "pep518", "pep621", "pep639", ] license = "MIT" repository = "https://github.com/PyO3/pyproject-toml-rs.git" [lib] name = "pyproject_toml" path = "src/lib.rs" [dependencies.glob] version = "0.3.1" optional = true [dependencies.indexmap] version = "2.6.0" features = ["serde"] [dependencies.pep440_rs] version = "0.7.2" [dependencies.pep508_rs] version = "0.9.1" [dependencies.serde] version = "1.0.214" features = ["derive"] [dependencies.thiserror] version = "1.0.65" [dependencies.toml] version = "0.8.19" features = ["parse"] default-features = false [dev-dependencies.insta] version = "1.41.0" [features] pep639-glob = ["glob"] tracing = [ "pep440_rs/tracing", "pep508_rs/tracing", ] pyproject-toml-0.13.4/Cargo.toml.orig000064400000000000000000000015551046102023000155760ustar 00000000000000[package] name = "pyproject-toml" version = "0.13.4" description = "pyproject.toml parser in Rust" edition = "2021" license = "MIT" keywords = ["pyproject", "pep517", "pep518", "pep621", "pep639"] readme = "README.md" repository = "https://github.com/PyO3/pyproject-toml-rs.git" rust-version = "1.64" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] glob = { version = "0.3.1", optional = true } indexmap = { version = "2.6.0", features = ["serde"] } pep440_rs = { version = "0.7.2" } pep508_rs = { version = "0.9.1" } serde = { version = "1.0.214", features = ["derive"] } thiserror = { version = "1.0.65" } toml = { version = "0.8.19", default-features = false, features = ["parse"] } [features] tracing = ["pep440_rs/tracing", "pep508_rs/tracing"] pep639-glob = ["glob"] [dev-dependencies] insta = "1.41.0" pyproject-toml-0.13.4/Changelog.md000064400000000000000000000024241046102023000151140ustar 00000000000000# Changelog ## 0.13.4 * Update pep440_rs to 0.7.2 * Update pep508_rs to 0.9.1 * Ensure wasm32-unknown-unknown support ## 0.13.3 * Make `license` and `license_files` public again * Add accessors for email / name on Contact * Add a method to resolve dependency groups into concrete lists of dependencies ## 0.13.2 * Make `Contact` definition strict ## 0.13.1 * Fix `Contact` definition ## 0.13.0 * Update to the provisional PEP 639. This is technically a breaking change, but only for fields previously in draft * Update pep440_rs to 0.7.1 * Update pep508_rs to 0.8.0 ## 0.12.0 * Support dependency groups (PEP 735) ## 0.11.0 * Update pep440_rs to 0.6.0 * Update pep508_rs to 0.6.0 ## 0.8.0 * The `build_system` table is now optional. There are many projects that use pyproject.toml for tool configuration without specifying a build backend, which this change reflects. ## 0.6.0 * Update to latest [PEP 639](https://peps.python.org/pep-0639) draft. The `license` key is now an enum that can either be an SPDX identifier or the previous table form, which accepting PEP 639 would deprecate. The previous implementation of a `project.license-expression` key in `pyproject.toml` has been [removed](https://peps.python.org/pep-0639/#define-a-new-top-level-license-expression-key). pyproject-toml-0.13.4/LICENSE000064400000000000000000000021061046102023000137050ustar 00000000000000MIT License Copyright (c) 2021-present PyO3 Project and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pyproject-toml-0.13.4/README.md000064400000000000000000000034751046102023000141710ustar 00000000000000# pyproject-toml-rs [![GitHub Actions](https://github.com/PyO3/pyproject-toml-rs/workflows/CI/badge.svg)](https://github.com/PyO3/pyproject-toml-rs/actions?query=workflow%3ACI) [![Crates.io](https://img.shields.io/crates/v/pyproject-toml.svg)](https://crates.io/crates/pyproject-toml) [![docs.rs](https://docs.rs/pyproject-toml/badge.svg)](https://docs.rs/pyproject-toml/) `pyproject.toml` parser in Rust. ## Installation Add it to your ``Cargo.toml``: ```toml [dependencies] pyproject-toml = "0.8" ``` then you are good to go. If you are using Rust 2015 you have to add ``extern crate pyproject_toml`` to your crate root as well. ## Extended parsing If you want to add additional fields parsing, you can do it with [`serde`](https://github.com/serde-rs/serde)'s [`flatten`](https://serde.rs/field-attrs.html#flatten) feature and implement the [`Deref`](https://doc.rust-lang.org/std/ops/trait.Deref.html) trait, for example: ```rust use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PyProjectToml { #[serde(flatten)] inner: pyproject_toml::PyProjectToml, tool: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "kebab-case")] pub struct Tool { maturin: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "kebab-case")] pub struct ToolMaturin { sdist_include: Option>, } impl std::ops::Deref for PyProjectToml { type Target = pyproject_toml::PyProjectToml; fn deref(&self) -> &Self::Target { &self.inner } } impl PyProjectToml { pub fn new(content: &str) -> Result { toml::from_str(content) } } ``` ## License This work is released under the MIT license. A copy of the license is provided in the [LICENSE](./LICENSE) file. pyproject-toml-0.13.4/src/lib.rs000064400000000000000000000411211046102023000146030ustar 00000000000000#[cfg(feature = "pep639-glob")] mod pep639_glob; #[cfg(feature = "pep639-glob")] pub use pep639_glob::{parse_pep639_glob, Pep639GlobError}; pub mod pep735_resolve; use indexmap::IndexMap; use pep440_rs::{Version, VersionSpecifiers}; use pep508_rs::Requirement; use serde::{Deserialize, Serialize}; use std::ops::Deref; use std::path::PathBuf; /// The `[build-system]` section of a pyproject.toml as specified in PEP 517 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub struct BuildSystem { /// PEP 508 dependencies required to execute the build system pub requires: Vec, /// A string naming a Python object that will be used to perform the build pub build_backend: Option, /// Specify that their backend code is hosted in-tree, this key contains a list of directories pub backend_path: Option>, } /// A pyproject.toml as specified in PEP 517 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub struct PyProjectToml { /// Build-related data pub build_system: Option, /// Project metadata pub project: Option, /// Dependency groups table pub dependency_groups: Option, } /// PEP 621 project metadata #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub struct Project { /// The name of the project pub name: String, /// The version of the project as supported by PEP 440 pub version: Option, /// The summary description of the project pub description: Option, /// The full description of the project (i.e. the README) pub readme: Option, /// The Python version requirements of the project pub requires_python: Option, /// The license under which the project is distributed /// /// Supports both the current standard and the provisional PEP 639 pub license: Option, /// The paths to files containing licenses and other legal notices to be distributed with the /// project. /// /// Use `parse_pep639_glob` from the optional `pep639-glob` feature to find the matching files. /// /// Note that this doesn't check the PEP 639 rules for combining `license_files` and `license`. /// /// From the provisional PEP 639 pub license_files: Option>, /// The people or organizations considered to be the "authors" of the project pub authors: Option>, /// Similar to "authors" in that its exact meaning is open to interpretation pub maintainers: Option>, /// The keywords for the project pub keywords: Option>, /// Trove classifiers which apply to the project pub classifiers: Option>, /// A table of URLs where the key is the URL label and the value is the URL itself pub urls: Option>, /// Entry points pub entry_points: Option>>, /// Corresponds to the console_scripts group in the core metadata pub scripts: Option>, /// Corresponds to the gui_scripts group in the core metadata pub gui_scripts: Option>, /// Project dependencies pub dependencies: Option>, /// Optional dependencies pub optional_dependencies: Option>>, /// Specifies which fields listed by PEP 621 were intentionally unspecified /// so another tool can/will provide such metadata dynamically. pub dynamic: Option>, } impl Project { /// Initializes the only field mandatory in PEP 621 (`name`) and leaves everything else empty pub fn new(name: String) -> Self { Self { name, version: None, description: None, readme: None, requires_python: None, license: None, license_files: None, authors: None, maintainers: None, keywords: None, classifiers: None, urls: None, entry_points: None, scripts: None, gui_scripts: None, dependencies: None, optional_dependencies: None, dynamic: None, } } } /// The full description of the project (i.e. the README). #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] #[serde(untagged)] pub enum ReadMe { /// Relative path to a text file containing the full description RelativePath(String), /// Detailed readme information #[serde(rename_all = "kebab-case")] Table { /// A relative path to a file containing the full description file: Option, /// Full description text: Option, /// The content-type of the full description content_type: Option, }, } /// The optional `project.license` key /// /// Specified in . #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(untagged)] pub enum License { /// An SPDX Expression. /// /// Note that this doesn't check the validity of the SPDX expression or PEP 639 rules. /// /// From the provisional PEP 639. Spdx(String), Text { /// The full text of the license. text: String, }, File { /// The file containing the license text. file: PathBuf, }, } /// A `project.authors` or `project.maintainers` entry. /// /// Specified in /// . /// /// The entry is derived from the email format of `John Doe `. You need to /// provide at least name or email. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] // deny_unknown_fields prevents using the name field when the email is not a string. #[serde( untagged, deny_unknown_fields, expecting = "a table with 'name' and/or 'email' keys" )] pub enum Contact { /// TODO(konsti): RFC 822 validation. NameEmail { name: String, email: String }, /// TODO(konsti): RFC 822 validation. Name { name: String }, /// TODO(konsti): RFC 822 validation. Email { email: String }, } impl Contact { /// Returns the name of the contact. pub fn name(&self) -> Option<&str> { match self { Contact::NameEmail { name, .. } | Contact::Name { name } => Some(name), Contact::Email { .. } => None, } } /// Returns the email of the contact. pub fn email(&self) -> Option<&str> { match self { Contact::NameEmail { email, .. } | Contact::Email { email } => Some(email), Contact::Name { .. } => None, } } } /// The `[dependency-groups]` section of pyproject.toml, as specified in PEP 735 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(transparent)] pub struct DependencyGroups(pub IndexMap>); impl Deref for DependencyGroups { type Target = IndexMap>; fn deref(&self) -> &Self::Target { &self.0 } } /// A specifier item in a Dependency Group #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "kebab-case", untagged)] #[allow(clippy::large_enum_variant)] pub enum DependencyGroupSpecifier { /// PEP 508 requirement string String(Requirement), /// Include another dependency group #[serde(rename_all = "kebab-case")] Table { /// The name of the group to include include_group: String, }, } impl PyProjectToml { /// Parse `pyproject.toml` content pub fn new(content: &str) -> Result { toml::de::from_str(content) } } #[cfg(test)] mod tests { use super::{DependencyGroupSpecifier, License, PyProjectToml, ReadMe}; use pep440_rs::{Version, VersionSpecifiers}; use pep508_rs::Requirement; use std::path::PathBuf; use std::str::FromStr; #[test] fn test_parse_pyproject_toml() { let source = r#"[build-system] requires = ["maturin"] build-backend = "maturin" [project] name = "spam" version = "2020.0.0" description = "Lovely Spam! Wonderful Spam!" readme = "README.rst" requires-python = ">=3.8" license = {file = "LICENSE.txt"} keywords = ["egg", "bacon", "sausage", "tomatoes", "Lobster Thermidor"] authors = [ {email = "hi@pradyunsg.me"}, {name = "Tzu-Ping Chung"} ] maintainers = [ {name = "Brett Cannon", email = "brett@python.org"} ] classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python" ] dependencies = [ "httpx", "gidgethub[httpx]>4.0.0", "django>2.1; os_name != 'nt'", "django>2.0; os_name == 'nt'" ] [project.optional-dependencies] test = [ "pytest < 5.0.0", "pytest-cov[all]" ] [project.urls] homepage = "example.com" documentation = "readthedocs.org" repository = "github.com" changelog = "github.com/me/spam/blob/master/CHANGELOG.md" [project.scripts] spam-cli = "spam:main_cli" [project.gui-scripts] spam-gui = "spam:main_gui" [project.entry-points."spam.magical"] tomatoes = "spam:main_tomatoes""#; let project_toml = PyProjectToml::new(source).unwrap(); let build_system = &project_toml.build_system.unwrap(); assert_eq!( build_system.requires, &[Requirement::from_str("maturin").unwrap()] ); assert_eq!(build_system.build_backend.as_deref(), Some("maturin")); let project = project_toml.project.as_ref().unwrap(); assert_eq!(project.name, "spam"); assert_eq!( project.version, Some(Version::from_str("2020.0.0").unwrap()) ); assert_eq!( project.description.as_deref(), Some("Lovely Spam! Wonderful Spam!") ); assert_eq!( project.readme, Some(ReadMe::RelativePath("README.rst".to_string())) ); assert_eq!( project.requires_python, Some(VersionSpecifiers::from_str(">=3.8").unwrap()) ); assert_eq!( project.license, Some(License::File { file: PathBuf::from("LICENSE.txt"), }) ); assert_eq!( project.keywords.as_ref().unwrap(), &["egg", "bacon", "sausage", "tomatoes", "Lobster Thermidor"] ); assert_eq!( project.scripts.as_ref().unwrap()["spam-cli"], "spam:main_cli" ); assert_eq!( project.gui_scripts.as_ref().unwrap()["spam-gui"], "spam:main_gui" ); } #[test] fn test_parse_pyproject_toml_license_expression() { let source = r#"[build-system] requires = ["maturin"] build-backend = "maturin" [project] name = "spam" license = "MIT OR BSD-3-Clause" "#; let project_toml = PyProjectToml::new(source).unwrap(); let project = project_toml.project.as_ref().unwrap(); assert_eq!( project.license, Some(License::Spdx("MIT OR BSD-3-Clause".to_owned())) ); } /// https://peps.python.org/pep-0639/ #[test] fn test_parse_pyproject_toml_license_paths() { let source = r#"[build-system] requires = ["maturin"] build-backend = "maturin" [project] name = "spam" license = "MIT AND (Apache-2.0 OR BSD-2-Clause)" license-files = [ "LICENSE", "setuptools/_vendor/LICENSE", "setuptools/_vendor/LICENSE.APACHE", "setuptools/_vendor/LICENSE.BSD", ] "#; let project_toml = PyProjectToml::new(source).unwrap(); let project = project_toml.project.as_ref().unwrap(); assert_eq!( project.license, Some(License::Spdx( "MIT AND (Apache-2.0 OR BSD-2-Clause)".to_owned() )) ); assert_eq!( project.license_files, Some(vec![ "LICENSE".to_owned(), "setuptools/_vendor/LICENSE".to_owned(), "setuptools/_vendor/LICENSE.APACHE".to_owned(), "setuptools/_vendor/LICENSE.BSD".to_owned() ]) ); } // https://peps.python.org/pep-0639/ #[test] fn test_parse_pyproject_toml_license_globs() { let source = r#"[build-system] requires = ["maturin"] build-backend = "maturin" [project] name = "spam" license = "MIT AND (Apache-2.0 OR BSD-2-Clause)" license-files = [ "LICENSE*", "setuptools/_vendor/LICENSE*", ] "#; let project_toml = PyProjectToml::new(source).unwrap(); let project = project_toml.project.as_ref().unwrap(); assert_eq!( project.license, Some(License::Spdx( "MIT AND (Apache-2.0 OR BSD-2-Clause)".to_owned() )) ); assert_eq!( project.license_files, Some(vec![ "LICENSE*".to_owned(), "setuptools/_vendor/LICENSE*".to_owned(), ]) ); } #[test] fn test_parse_pyproject_toml_default_license_files() { let source = r#"[build-system] requires = ["maturin"] build-backend = "maturin" [project] name = "spam" "#; let project_toml = PyProjectToml::new(source).unwrap(); let project = project_toml.project.as_ref().unwrap(); // Changed from the PEP 639 draft. assert_eq!(project.license_files.clone(), None); } #[test] fn test_parse_pyproject_toml_readme_content_type() { let source = r#"[build-system] requires = ["maturin"] build-backend = "maturin" [project] name = "spam" readme = {text = "ReadMe!", content-type = "text/plain"} "#; let project_toml = PyProjectToml::new(source).unwrap(); let project = project_toml.project.as_ref().unwrap(); assert_eq!( project.readme, Some(ReadMe::Table { file: None, text: Some("ReadMe!".to_string()), content_type: Some("text/plain".to_string()) }) ); } #[test] fn test_parse_pyproject_toml_dependency_groups() { let source = r#"[dependency-groups] alpha = ["beta", "gamma", "delta"] epsilon = ["eta<2.0", "theta==2024.09.01"] iota = [{include-group = "alpha"}] "#; let project_toml = PyProjectToml::new(source).unwrap(); let dependency_groups = project_toml.dependency_groups.as_ref().unwrap(); assert_eq!( dependency_groups["alpha"], vec![ DependencyGroupSpecifier::String(Requirement::from_str("beta").unwrap()), DependencyGroupSpecifier::String(Requirement::from_str("gamma").unwrap()), DependencyGroupSpecifier::String(Requirement::from_str("delta").unwrap(),) ] ); assert_eq!( dependency_groups["epsilon"], vec![ DependencyGroupSpecifier::String(Requirement::from_str("eta<2.0").unwrap()), DependencyGroupSpecifier::String( Requirement::from_str("theta==2024.09.01").unwrap() ) ] ); assert_eq!( dependency_groups["iota"], vec![DependencyGroupSpecifier::Table { include_group: "alpha".to_string() }] ); } #[test] fn invalid_email() { let source = r#" [project] name = "hello-world" version = "0.1.0" # Ensure that the spans from toml handle utf-8 correctly authors = [ { name = "Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘", email = 1 } ] "#; let err = PyProjectToml::new(source).unwrap_err(); assert_eq!( err.to_string(), "TOML parse error at line 6, column 11 | 6 | authors = [ | ^ a table with 'name' and/or 'email' keys " ); } #[test] fn test_contact_accessors() { let contact = super::Contact::NameEmail { name: "John Doe".to_string(), email: "john@example.com".to_string(), }; assert_eq!(contact.name(), Some("John Doe")); assert_eq!(contact.email(), Some("john@example.com")); let contact = super::Contact::Name { name: "John Doe".to_string(), }; assert_eq!(contact.name(), Some("John Doe")); assert_eq!(contact.email(), None); let contact = super::Contact::Email { email: "john@example.com".to_string(), }; assert_eq!(contact.name(), None); assert_eq!(contact.email(), Some("john@example.com")); } } pyproject-toml-0.13.4/src/pep639_glob.rs000064400000000000000000000123501046102023000160700ustar 00000000000000//! Implementation of PEP 639 cross-language restricted globs. use glob::{Pattern, PatternError}; use thiserror::Error; #[derive(Debug, Error)] pub enum Pep639GlobError { #[error(transparent)] PatternError(#[from] PatternError), #[error("The parent directory operator (`..`) at position {pos} is not allowed in license file globs")] ParentDirectory { pos: usize }, #[error("Glob contains invalid character at position {pos}: `{invalid}`")] InvalidCharacter { pos: usize, invalid: char }, #[error("Glob contains invalid character in range at position {pos}: `{invalid}`")] InvalidCharacterRange { pos: usize, invalid: char }, } /// Parse a PEP 639 `license-files` glob /// /// The syntax is more restricted than regular globbing in Python or Rust for platform independent /// results. Since [`glob::Pattern`] is a superset over this format, we can use it after validating /// that no unsupported features are in the string. /// /// From [PEP 639](https://peps.python.org/pep-0639/#add-license-files-key): /// /// > Its value is an array of strings which MUST contain valid glob patterns, /// > as specified below: /// > /// > - Alphanumeric characters, underscores (`_`), hyphens (`-`) and dots (`.`) /// > MUST be matched verbatim. /// > /// > - Special glob characters: `*`, `?`, `**` and character ranges: `[]` /// > containing only the verbatim matched characters MUST be supported. /// > Within `[...]`, the hyphen indicates a range (e.g. `a-z`). /// > Hyphens at the start or end are matched literally. /// > /// > - Path delimiters MUST be the forward slash character (`/`). /// > Patterns are relative to the directory containing `pyproject.toml`, /// > therefore the leading slash character MUST NOT be used. /// > /// > - Parent directory indicators (`..`) MUST NOT be used. /// > /// > Any characters or character sequences not covered by this specification are /// > invalid. Projects MUST NOT use such values. /// > Tools consuming this field MAY reject invalid values with an error. pub fn parse_pep639_glob(glob: &str) -> Result { let mut chars = glob.chars().enumerate().peekable(); // A `..` is on a parent directory indicator at the start of the string or after a directory // separator. let mut start_or_slash = true; while let Some((pos, c)) = chars.next() { if c.is_alphanumeric() || matches!(c, '_' | '-' | '*' | '?') { start_or_slash = false; } else if c == '.' { if start_or_slash && matches!(chars.peek(), Some((_, '.'))) { return Err(Pep639GlobError::ParentDirectory { pos }); } start_or_slash = false; } else if c == '/' { start_or_slash = true; } else if c == '[' { for (pos, c) in chars.by_ref() { // TODO: https://discuss.python.org/t/pep-639-round-3-improving-license-clarity-with-better-package-metadata/53020/98 if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') { // Allowed. } else if c == ']' { break; } else { return Err(Pep639GlobError::InvalidCharacterRange { pos, invalid: c }); } } start_or_slash = false; } else { return Err(Pep639GlobError::InvalidCharacter { pos, invalid: c }); } } Ok(Pattern::new(glob)?) } #[cfg(test)] mod tests { use super::*; use insta::assert_snapshot; #[test] fn test_error() { let parse_err = |glob| parse_pep639_glob(glob).unwrap_err().to_string(); assert_snapshot!( parse_err(".."), @"The parent directory operator (`..`) at position 0 is not allowed in license file globs" ); assert_snapshot!( parse_err("licenses/.."), @"The parent directory operator (`..`) at position 9 is not allowed in license file globs" ); assert_snapshot!( parse_err("licenses/LICEN!E.txt"), @"Glob contains invalid character at position 14: `!`" ); assert_snapshot!( parse_err("licenses/LICEN[!C]E.txt"), @"Glob contains invalid character in range at position 15: `!`" ); assert_snapshot!( parse_err("licenses/LICEN[C?]E.txt"), @"Glob contains invalid character in range at position 16: `?`" ); assert_snapshot!(parse_err("******"), @"Pattern syntax error near position 2: wildcards are either regular `*` or recursive `**`"); assert_snapshot!( parse_err(r"licenses\eula.txt"), @r"Glob contains invalid character at position 8: `\`" ); } #[test] fn test_valid() { let cases = [ "licenses/*.txt", "licenses/**/*.txt", "LICEN[CS]E.txt", "LICEN?E.txt", "[a-z].txt", "[a-z._-].txt", "*/**", "LICENSE..txt", "LICENSE_file-1.txt", // (google translate) "licenses/라이센스*.txt", "licenses/ライセンス*.txt", "licenses/执照*.txt", ]; for case in cases { parse_pep639_glob(case).unwrap(); } } } pyproject-toml-0.13.4/src/pep735_resolve.rs000064400000000000000000000124551046102023000166270ustar 00000000000000use indexmap::IndexMap; use pep508_rs::Requirement; use thiserror::Error; use crate::{DependencyGroupSpecifier, DependencyGroups}; #[derive(Debug, Error)] pub enum Pep735Error { #[error("Failed to find group `{0}` included by `{1}`")] GroupNotFound(String, String), #[error("Detected a cycle in `dependency-groups`: {0}")] DependencyGroupCycle(Cycle), } /// A cycle in the `dependency-groups` table. #[derive(Debug)] pub struct Cycle(Vec); /// Display a cycle, e.g., `a -> b -> c -> a`. impl std::fmt::Display for Cycle { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let [first, rest @ ..] = self.0.as_slice() else { return Ok(()); }; write!(f, "`{first}`")?; for group in rest { write!(f, " -> `{group}`")?; } write!(f, " -> `{first}`")?; Ok(()) } } impl DependencyGroups { /// Resolve dependency groups (which may contain references to other groups) into concrete /// lists of requirements. pub fn resolve(&self) -> Result>, Pep735Error> { // Helper function to resolves a single group fn resolve_single<'a>( groups: &'a DependencyGroups, group: &'a str, resolved: &mut IndexMap>, parents: &mut Vec<&'a str>, ) -> Result<(), Pep735Error> { let Some(specifiers) = groups.get(group) else { // If the group included in another group does not exist, return an error let parent = parents.iter().last().expect("should have a parent"); return Err(Pep735Error::GroupNotFound( group.to_string(), parent.to_string(), )); }; // If there is a cycle in dependency groups, return an error if parents.contains(&group) { return Err(Pep735Error::DependencyGroupCycle(Cycle( parents.iter().map(|s| s.to_string()).collect(), ))); } // If the dependency group has already been resolved, exit early if resolved.get(group).is_some() { return Ok(()); } // Otherwise, perform recursion, as required, on the dependency group's specifiers parents.push(group); let mut requirements = Vec::with_capacity(specifiers.len()); for spec in specifiers.iter() { match spec { // It's a requirement. Just add it to the Vec of resolved requirements DependencyGroupSpecifier::String(requirement) => { requirements.push(requirement.clone()) } // It's a reference to another group. Recurse into it DependencyGroupSpecifier::Table { include_group } => { resolve_single(groups, include_group, resolved, parents)?; requirements .extend(resolved.get(include_group).into_iter().flatten().cloned()); } } } // Add the resolved group to IndexMap resolved.insert(group.to_string(), requirements.clone()); parents.pop(); Ok(()) } let mut resolved = IndexMap::new(); for group in self.keys() { resolve_single(self, group, &mut resolved, &mut Vec::new())?; } Ok(resolved) } } #[cfg(test)] mod tests { use pep508_rs::Requirement; use std::str::FromStr; use crate::PyProjectToml; #[test] fn test_parse_pyproject_toml_dependency_groups_resolve() { let source = r#"[dependency-groups] alpha = ["beta", "gamma", "delta"] epsilon = ["eta<2.0", "theta==2024.09.01"] iota = [{include-group = "alpha"}] "#; let project_toml = PyProjectToml::new(source).unwrap(); let dependency_groups = project_toml.dependency_groups.as_ref().unwrap(); assert_eq!( dependency_groups.resolve().unwrap()["iota"], vec![ Requirement::from_str("beta").unwrap(), Requirement::from_str("gamma").unwrap(), Requirement::from_str("delta").unwrap() ] ); } #[test] fn test_parse_pyproject_toml_dependency_groups_cycle() { let source = r#"[dependency-groups] alpha = [{include-group = "iota"}] iota = [{include-group = "alpha"}] "#; let project_toml = PyProjectToml::new(source).unwrap(); let dependency_groups = project_toml.dependency_groups.as_ref().unwrap(); assert_eq!( dependency_groups.resolve().unwrap_err().to_string(), String::from("Detected a cycle in `dependency-groups`: `alpha` -> `iota` -> `alpha`") ) } #[test] fn test_parse_pyproject_toml_dependency_groups_missing_include() { let source = r#"[dependency-groups] iota = [{include-group = "alpha"}] "#; let project_toml = PyProjectToml::new(source).unwrap(); let dependency_groups = project_toml.dependency_groups.as_ref().unwrap(); assert_eq!( dependency_groups.resolve().unwrap_err().to_string(), String::from("Failed to find group `alpha` included by `iota`") ) } }