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 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 #[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#[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 #[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 #[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 #[must_use]
92 pub fn with_project(mut self, project: PyProjectProjectMetadata) -> Self {
93 self.project = Some(project);
94 self
95 }
96
97 #[must_use]
99 pub fn with_tool_section(mut self, section: PyProjectToolSection) -> Self {
100 self.tool_sections.push(section);
101 self
102 }
103
104 #[must_use]
106 pub fn project_name(&self) -> Option<&str> {
107 self.project
108 .as_ref()
109 .and_then(PyProjectProjectMetadata::name)
110 }
111
112 #[must_use]
114 pub fn project_version(&self) -> Option<&str> {
115 self.project
116 .as_ref()
117 .and_then(PyProjectProjectMetadata::version)
118 }
119
120 #[must_use]
122 pub fn dependencies(&self) -> &[PyProjectDependency] {
123 self.project
124 .as_ref()
125 .map_or(&[], PyProjectProjectMetadata::dependencies)
126 }
127
128 #[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 #[must_use]
138 pub fn scripts(&self) -> &[PyProjectScript] {
139 self.project
140 .as_ref()
141 .map_or(&[], PyProjectProjectMetadata::scripts)
142 }
143
144 #[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 #[must_use]
154 pub fn tool_sections(&self) -> &[PyProjectToolSection] {
155 &self.tool_sections
156 }
157}
158
159#[derive(Clone, Debug, Eq, PartialEq)]
161pub struct PyProjectBuildSystem {
162 requires: Vec<PyProjectDependency>,
163 build_backend: Option<PyProjectBuildBackend>,
164}
165
166impl PyProjectBuildSystem {
167 #[must_use]
169 pub const fn new() -> Self {
170 Self {
171 requires: Vec::new(),
172 build_backend: None,
173 }
174 }
175
176 #[must_use]
178 pub fn with_requirement(mut self, requirement: PyProjectDependency) -> Self {
179 self.requires.push(requirement);
180 self
181 }
182
183 #[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 #[must_use]
192 pub fn requires(&self) -> &[PyProjectDependency] {
193 &self.requires
194 }
195
196 #[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#[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 #[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 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 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 #[must_use]
256 pub fn with_dependency(mut self, dependency: PyProjectDependency) -> Self {
257 self.dependencies.push(dependency);
258 self
259 }
260
261 #[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 #[must_use]
273 pub fn with_script(mut self, script: PyProjectScript) -> Self {
274 self.scripts.push(script);
275 self
276 }
277
278 #[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 #[must_use]
287 pub fn name(&self) -> Option<&str> {
288 self.name.as_deref()
289 }
290
291 #[must_use]
293 pub fn version(&self) -> Option<&str> {
294 self.version.as_deref()
295 }
296
297 #[must_use]
299 pub fn dependencies(&self) -> &[PyProjectDependency] {
300 &self.dependencies
301 }
302
303 #[must_use]
305 pub fn optional_dependency_groups(&self) -> &[PyProjectOptionalDependencyGroup] {
306 &self.optional_dependency_groups
307 }
308
309 #[must_use]
311 pub fn scripts(&self) -> &[PyProjectScript] {
312 &self.scripts
313 }
314
315 #[must_use]
317 pub fn entry_points(&self) -> &[PyProjectEntryPoint] {
318 &self.entry_points
319 }
320}
321
322#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
324pub enum PyProjectConfigFile {
325 PyProjectToml,
326}
327
328impl PyProjectConfigFile {
329 #[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#[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 #[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#[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}