Skip to main content

use_pyproject/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7macro_rules! text_newtype {
8    ($name:ident) => {
9        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10        pub struct $name(String);
11
12        impl $name {
13            /// Creates non-empty pyproject metadata text.
14            ///
15            /// # Errors
16            ///
17            /// Returns [`PyProjectTextError::Empty`] when `input` is empty after trimming.
18            pub fn new(input: &str) -> Result<Self, PyProjectTextError> {
19                let trimmed = input.trim();
20                if trimmed.is_empty() {
21                    Err(PyProjectTextError::Empty)
22                } else {
23                    Ok(Self(trimmed.to_string()))
24                }
25            }
26
27            /// Returns the stored text.
28            #[must_use]
29            pub fn as_str(&self) -> &str {
30                &self.0
31            }
32        }
33
34        impl fmt::Display for $name {
35            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36                formatter.write_str(self.as_str())
37            }
38        }
39
40        impl FromStr for $name {
41            type Err = PyProjectTextError;
42
43            fn from_str(input: &str) -> Result<Self, Self::Err> {
44                Self::new(input)
45            }
46        }
47
48        impl TryFrom<&str> for $name {
49            type Error = PyProjectTextError;
50
51            fn try_from(value: &str) -> Result<Self, Self::Error> {
52                Self::new(value)
53            }
54        }
55    };
56}
57
58text_newtype!(PyProjectDependency);
59text_newtype!(PyProjectOptionalDependencyGroup);
60text_newtype!(PyProjectScript);
61text_newtype!(PyProjectEntryPoint);
62text_newtype!(PyProjectToolSection);
63
64/// Partial `pyproject.toml` metadata.
65#[derive(Clone, Debug, Default, Eq, PartialEq)]
66pub struct PyProject {
67    build_system: Option<PyProjectBuildSystem>,
68    project: Option<PyProjectProjectMetadata>,
69    tool_sections: Vec<PyProjectToolSection>,
70}
71
72impl PyProject {
73    /// Creates empty pyproject metadata.
74    #[must_use]
75    pub const fn new() -> Self {
76        Self {
77            build_system: None,
78            project: None,
79            tool_sections: Vec::new(),
80        }
81    }
82
83    /// Adds build-system metadata.
84    #[must_use]
85    pub fn with_build_system(mut self, build_system: PyProjectBuildSystem) -> Self {
86        self.build_system = Some(build_system);
87        self
88    }
89
90    /// Adds project metadata.
91    #[must_use]
92    pub fn with_project(mut self, project: PyProjectProjectMetadata) -> Self {
93        self.project = Some(project);
94        self
95    }
96
97    /// Adds a tool section label.
98    #[must_use]
99    pub fn with_tool_section(mut self, section: PyProjectToolSection) -> Self {
100        self.tool_sections.push(section);
101        self
102    }
103
104    /// Returns the project name when present.
105    #[must_use]
106    pub fn project_name(&self) -> Option<&str> {
107        self.project
108            .as_ref()
109            .and_then(PyProjectProjectMetadata::name)
110    }
111
112    /// Returns the project version when present.
113    #[must_use]
114    pub fn project_version(&self) -> Option<&str> {
115        self.project
116            .as_ref()
117            .and_then(PyProjectProjectMetadata::version)
118    }
119
120    /// Returns declared dependencies when project metadata is present.
121    #[must_use]
122    pub fn dependencies(&self) -> &[PyProjectDependency] {
123        self.project
124            .as_ref()
125            .map_or(&[], PyProjectProjectMetadata::dependencies)
126    }
127
128    /// Returns optional dependency group labels when present.
129    #[must_use]
130    pub fn optional_dependency_groups(&self) -> &[PyProjectOptionalDependencyGroup] {
131        self.project
132            .as_ref()
133            .map_or(&[], PyProjectProjectMetadata::optional_dependency_groups)
134    }
135
136    /// Returns script labels when present.
137    #[must_use]
138    pub fn scripts(&self) -> &[PyProjectScript] {
139        self.project
140            .as_ref()
141            .map_or(&[], PyProjectProjectMetadata::scripts)
142    }
143
144    /// Returns the build backend when present.
145    #[must_use]
146    pub fn build_backend(&self) -> Option<&PyProjectBuildBackend> {
147        self.build_system
148            .as_ref()
149            .and_then(PyProjectBuildSystem::build_backend)
150    }
151
152    /// Returns tool section labels.
153    #[must_use]
154    pub fn tool_sections(&self) -> &[PyProjectToolSection] {
155        &self.tool_sections
156    }
157}
158
159/// Partial `[build-system]` metadata.
160#[derive(Clone, Debug, Eq, PartialEq)]
161pub struct PyProjectBuildSystem {
162    requires: Vec<PyProjectDependency>,
163    build_backend: Option<PyProjectBuildBackend>,
164}
165
166impl PyProjectBuildSystem {
167    /// Creates build-system metadata.
168    #[must_use]
169    pub const fn new() -> Self {
170        Self {
171            requires: Vec::new(),
172            build_backend: None,
173        }
174    }
175
176    /// Adds a build requirement.
177    #[must_use]
178    pub fn with_requirement(mut self, requirement: PyProjectDependency) -> Self {
179        self.requires.push(requirement);
180        self
181    }
182
183    /// Adds a build backend label.
184    #[must_use]
185    pub fn with_build_backend(mut self, build_backend: PyProjectBuildBackend) -> Self {
186        self.build_backend = Some(build_backend);
187        self
188    }
189
190    /// Returns build requirements.
191    #[must_use]
192    pub fn requires(&self) -> &[PyProjectDependency] {
193        &self.requires
194    }
195
196    /// Returns the build backend when present.
197    #[must_use]
198    pub const fn build_backend(&self) -> Option<&PyProjectBuildBackend> {
199        self.build_backend.as_ref()
200    }
201}
202
203impl Default for PyProjectBuildSystem {
204    fn default() -> Self {
205        Self::new()
206    }
207}
208
209/// Partial `[project]` metadata.
210#[derive(Clone, Debug, Default, Eq, PartialEq)]
211pub struct PyProjectProjectMetadata {
212    name: Option<String>,
213    version: Option<String>,
214    dependencies: Vec<PyProjectDependency>,
215    optional_dependency_groups: Vec<PyProjectOptionalDependencyGroup>,
216    scripts: Vec<PyProjectScript>,
217    entry_points: Vec<PyProjectEntryPoint>,
218}
219
220impl PyProjectProjectMetadata {
221    /// Creates empty project metadata.
222    #[must_use]
223    pub const fn new() -> Self {
224        Self {
225            name: None,
226            version: None,
227            dependencies: Vec::new(),
228            optional_dependency_groups: Vec::new(),
229            scripts: Vec::new(),
230            entry_points: Vec::new(),
231        }
232    }
233
234    /// Sets the project name.
235    ///
236    /// # Errors
237    ///
238    /// Returns [`PyProjectTextError::Empty`] when `name` is empty after trimming.
239    pub fn with_name(mut self, name: &str) -> Result<Self, PyProjectTextError> {
240        self.name = Some(non_empty_text(name)?.to_string());
241        Ok(self)
242    }
243
244    /// Sets the project version.
245    ///
246    /// # Errors
247    ///
248    /// Returns [`PyProjectTextError::Empty`] when `version` is empty after trimming.
249    pub fn with_version(mut self, version: &str) -> Result<Self, PyProjectTextError> {
250        self.version = Some(non_empty_text(version)?.to_string());
251        Ok(self)
252    }
253
254    /// Adds a dependency.
255    #[must_use]
256    pub fn with_dependency(mut self, dependency: PyProjectDependency) -> Self {
257        self.dependencies.push(dependency);
258        self
259    }
260
261    /// Adds an optional dependency group label.
262    #[must_use]
263    pub fn with_optional_dependency_group(
264        mut self,
265        group: PyProjectOptionalDependencyGroup,
266    ) -> Self {
267        self.optional_dependency_groups.push(group);
268        self
269    }
270
271    /// Adds a script label.
272    #[must_use]
273    pub fn with_script(mut self, script: PyProjectScript) -> Self {
274        self.scripts.push(script);
275        self
276    }
277
278    /// Adds an entry point label.
279    #[must_use]
280    pub fn with_entry_point(mut self, entry_point: PyProjectEntryPoint) -> Self {
281        self.entry_points.push(entry_point);
282        self
283    }
284
285    /// Returns the project name when present.
286    #[must_use]
287    pub fn name(&self) -> Option<&str> {
288        self.name.as_deref()
289    }
290
291    /// Returns the project version when present.
292    #[must_use]
293    pub fn version(&self) -> Option<&str> {
294        self.version.as_deref()
295    }
296
297    /// Returns dependencies.
298    #[must_use]
299    pub fn dependencies(&self) -> &[PyProjectDependency] {
300        &self.dependencies
301    }
302
303    /// Returns optional dependency group labels.
304    #[must_use]
305    pub fn optional_dependency_groups(&self) -> &[PyProjectOptionalDependencyGroup] {
306        &self.optional_dependency_groups
307    }
308
309    /// Returns script labels.
310    #[must_use]
311    pub fn scripts(&self) -> &[PyProjectScript] {
312        &self.scripts
313    }
314
315    /// Returns entry point labels.
316    #[must_use]
317    pub fn entry_points(&self) -> &[PyProjectEntryPoint] {
318        &self.entry_points
319    }
320}
321
322/// Common pyproject config file labels.
323#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
324pub enum PyProjectConfigFile {
325    PyProjectToml,
326}
327
328impl PyProjectConfigFile {
329    /// Returns the config file label.
330    #[must_use]
331    pub const fn as_str(self) -> &'static str {
332        "pyproject.toml"
333    }
334}
335
336impl fmt::Display for PyProjectConfigFile {
337    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
338        formatter.write_str(self.as_str())
339    }
340}
341
342impl FromStr for PyProjectConfigFile {
343    type Err = PyProjectTextError;
344
345    fn from_str(input: &str) -> Result<Self, Self::Err> {
346        match normalized_label(input)?.as_str() {
347            "pyprojecttoml" => Ok(Self::PyProjectToml),
348            _ => Err(PyProjectTextError::UnknownLabel),
349        }
350    }
351}
352
353/// Common Python build backends.
354#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
355pub enum PyProjectBuildBackend {
356    SetuptoolsBuildMeta,
357    HatchlingBuild,
358    PoetryCore,
359    FlitCore,
360    Maturin,
361    ScikitBuildCore,
362    Custom(String),
363}
364
365impl PyProjectBuildBackend {
366    /// Returns the normalized build backend label.
367    #[must_use]
368    pub const fn as_str(&self) -> &str {
369        match self {
370            Self::SetuptoolsBuildMeta => "setuptools.build_meta",
371            Self::HatchlingBuild => "hatchling.build",
372            Self::PoetryCore => "poetry.core.masonry.api",
373            Self::FlitCore => "flit_core.buildapi",
374            Self::Maturin => "maturin",
375            Self::ScikitBuildCore => "scikit_build_core.build",
376            Self::Custom(label) => label.as_str(),
377        }
378    }
379}
380
381impl fmt::Display for PyProjectBuildBackend {
382    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
383        formatter.write_str(self.as_str())
384    }
385}
386
387impl FromStr for PyProjectBuildBackend {
388    type Err = PyProjectTextError;
389
390    fn from_str(input: &str) -> Result<Self, Self::Err> {
391        let trimmed = non_empty_text(input)?;
392        Ok(match trimmed {
393            "setuptools.build_meta" => Self::SetuptoolsBuildMeta,
394            "hatchling.build" => Self::HatchlingBuild,
395            "poetry.core.masonry.api" => Self::PoetryCore,
396            "flit_core.buildapi" => Self::FlitCore,
397            "maturin" => Self::Maturin,
398            "scikit_build_core.build" => Self::ScikitBuildCore,
399            _ => Self::Custom(trimmed.to_string()),
400        })
401    }
402}
403
404/// Error returned when pyproject metadata text is invalid.
405#[derive(Clone, Copy, Debug, Eq, PartialEq)]
406pub enum PyProjectTextError {
407    Empty,
408    UnknownLabel,
409}
410
411impl fmt::Display for PyProjectTextError {
412    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
413        match self {
414            Self::Empty => formatter.write_str("pyproject metadata text cannot be empty"),
415            Self::UnknownLabel => formatter.write_str("unknown pyproject metadata label"),
416        }
417    }
418}
419
420impl Error for PyProjectTextError {}
421
422fn non_empty_text(input: &str) -> Result<&str, PyProjectTextError> {
423    let trimmed = input.trim();
424    if trimmed.is_empty() {
425        Err(PyProjectTextError::Empty)
426    } else {
427        Ok(trimmed)
428    }
429}
430
431fn normalized_label(input: &str) -> Result<String, PyProjectTextError> {
432    let trimmed = input.trim();
433    if trimmed.is_empty() {
434        Err(PyProjectTextError::Empty)
435    } else {
436        Ok(trimmed
437            .to_ascii_lowercase()
438            .replace(['-', '_', '.', ' '], ""))
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::{
445        PyProject, PyProjectBuildBackend, PyProjectBuildSystem, PyProjectConfigFile,
446        PyProjectDependency, PyProjectProjectMetadata, PyProjectTextError,
447    };
448
449    #[test]
450    fn models_partial_pyproject_metadata() -> Result<(), PyProjectTextError> {
451        let project = PyProjectProjectMetadata::new()
452            .with_name("demo")?
453            .with_version("0.1.0")?
454            .with_dependency(PyProjectDependency::new("requests>=2")?);
455        let build_system = PyProjectBuildSystem::new()
456            .with_requirement(PyProjectDependency::new("hatchling")?)
457            .with_build_backend(PyProjectBuildBackend::HatchlingBuild);
458        let pyproject = PyProject::new()
459            .with_project(project)
460            .with_build_system(build_system);
461
462        assert_eq!(pyproject.project_name(), Some("demo"));
463        assert_eq!(pyproject.project_version(), Some("0.1.0"));
464        assert_eq!(pyproject.dependencies()[0].as_str(), "requests>=2");
465        assert_eq!(
466            pyproject.build_backend(),
467            Some(&PyProjectBuildBackend::HatchlingBuild)
468        );
469        Ok(())
470    }
471
472    #[test]
473    fn parses_known_and_custom_backends() -> Result<(), PyProjectTextError> {
474        assert_eq!(
475            "maturin".parse::<PyProjectBuildBackend>()?,
476            PyProjectBuildBackend::Maturin
477        );
478        assert_eq!(
479            "pyproject.toml".parse::<PyProjectConfigFile>()?,
480            PyProjectConfigFile::PyProjectToml
481        );
482        assert_eq!(
483            PyProjectConfigFile::PyProjectToml.to_string(),
484            "pyproject.toml"
485        );
486        assert_eq!(
487            "custom.backend".parse::<PyProjectBuildBackend>()?.as_str(),
488            "custom.backend"
489        );
490        Ok(())
491    }
492}