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#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
19#[serde(rename_all = "kebab-case")]
20pub struct BuildSystem {
21 pub requires: Vec<Requirement>,
23 pub build_backend: Option<String>,
25 pub backend_path: Option<Vec<String>>,
27}
28
29#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
31#[serde(rename_all = "kebab-case")]
32pub struct PyProjectToml {
33 pub build_system: Option<BuildSystem>,
35 pub project: Option<Project>,
37 pub dependency_groups: Option<DependencyGroups>,
39}
40
41#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
43#[serde(rename_all = "kebab-case")]
44pub struct Project {
45 pub name: String,
48 pub version: Option<Version>,
50 pub description: Option<String>,
52 pub readme: Option<ReadMe>,
54 pub requires_python: Option<VersionSpecifiers>,
56 pub license: Option<License>,
60 pub license_files: Option<Vec<String>>,
69 pub authors: Option<Vec<Contact>>,
71 pub maintainers: Option<Vec<Contact>>,
73 pub keywords: Option<Vec<String>>,
75 pub classifiers: Option<Vec<String>>,
77 pub urls: Option<IndexMap<String, String>>,
79 pub entry_points: Option<IndexMap<String, IndexMap<String, String>>>,
81 pub scripts: Option<IndexMap<String, String>>,
83 pub gui_scripts: Option<IndexMap<String, String>>,
85 pub dependencies: Option<Vec<Requirement>>,
87 pub optional_dependencies: Option<IndexMap<String, Vec<Requirement>>>,
90 pub dynamic: Option<Vec<String>>,
93}
94
95impl Project {
96 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#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
123#[serde(rename_all = "kebab-case")]
124#[serde(untagged)]
125pub enum ReadMe {
126 RelativePath(String),
128 #[serde(rename_all = "kebab-case")]
130 Table {
131 file: Option<String>,
133 text: Option<String>,
135 content_type: Option<String>,
137 },
138}
139
140#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
144#[serde(untagged)]
145pub enum License {
146 Spdx(String),
152 Text {
153 text: String,
155 },
156 File {
157 file: PathBuf,
159 },
160}
161
162#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
170#[serde(
172 untagged,
173 deny_unknown_fields,
174 expecting = "a table with 'name' and/or 'email' keys"
175)]
176pub enum Contact {
177 NameEmail { name: String, email: String },
179 Name { name: String },
181 Email { email: String },
183}
184
185impl Contact {
186 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 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#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
205#[serde(transparent)]
206pub 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#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
219#[serde(rename_all = "kebab-case", untagged)]
220#[allow(clippy::large_enum_variant)]
221pub enum DependencyGroupSpecifier {
222 String(Requirement),
224 #[serde(rename_all = "kebab-case")]
226 Table {
227 include_group: String,
229 },
230}
231
232#[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 pub fn new(content: &str) -> Result<Self, toml::de::Error> {
245 toml::de::from_str(content)
246 }
247
248 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 #[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 #[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 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}