1#[cfg(feature = "pep639-glob")]
2mod pep639_glob;
3
4#[cfg(feature = "pep639-glob")]
5pub use pep639_glob::{check_pep639_glob, parse_pep639_glob, Pep639GlobError};
6
7pub mod pep735_resolve;
8
9use indexmap::IndexMap;
10use pep440_rs::{Version, VersionSpecifiers};
11use pep508_rs::Requirement;
12use serde::{Deserialize, Serialize};
13use std::ops::Deref;
14use std::path::PathBuf;
15
16#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
18#[serde(rename_all = "kebab-case")]
19pub struct BuildSystem {
20 pub requires: Vec<Requirement>,
22 pub build_backend: Option<String>,
24 pub backend_path: Option<Vec<String>>,
26}
27
28#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
30#[serde(rename_all = "kebab-case")]
31pub struct PyProjectToml {
32 pub build_system: Option<BuildSystem>,
34 pub project: Option<Project>,
36 pub dependency_groups: Option<DependencyGroups>,
38}
39
40#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
42#[serde(rename_all = "kebab-case")]
43pub struct Project {
44 pub name: String,
46 pub version: Option<Version>,
48 pub description: Option<String>,
50 pub readme: Option<ReadMe>,
52 pub requires_python: Option<VersionSpecifiers>,
54 pub license: Option<License>,
58 pub license_files: Option<Vec<String>>,
67 pub authors: Option<Vec<Contact>>,
69 pub maintainers: Option<Vec<Contact>>,
71 pub keywords: Option<Vec<String>>,
73 pub classifiers: Option<Vec<String>>,
75 pub urls: Option<IndexMap<String, String>>,
77 pub entry_points: Option<IndexMap<String, IndexMap<String, String>>>,
79 pub scripts: Option<IndexMap<String, String>>,
81 pub gui_scripts: Option<IndexMap<String, String>>,
83 pub dependencies: Option<Vec<Requirement>>,
85 pub optional_dependencies: Option<IndexMap<String, Vec<Requirement>>>,
87 pub dynamic: Option<Vec<String>>,
90}
91
92impl Project {
93 pub fn new(name: String) -> Self {
95 Self {
96 name,
97 version: None,
98 description: None,
99 readme: None,
100 requires_python: None,
101 license: None,
102 license_files: None,
103 authors: None,
104 maintainers: None,
105 keywords: None,
106 classifiers: None,
107 urls: None,
108 entry_points: None,
109 scripts: None,
110 gui_scripts: None,
111 dependencies: None,
112 optional_dependencies: None,
113 dynamic: None,
114 }
115 }
116}
117
118#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
120#[serde(rename_all = "kebab-case")]
121#[serde(untagged)]
122pub enum ReadMe {
123 RelativePath(String),
125 #[serde(rename_all = "kebab-case")]
127 Table {
128 file: Option<String>,
130 text: Option<String>,
132 content_type: Option<String>,
134 },
135}
136
137#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
141#[serde(untagged)]
142pub enum License {
143 Spdx(String),
149 Text {
150 text: String,
152 },
153 File {
154 file: PathBuf,
156 },
157}
158
159#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
167#[serde(
169 untagged,
170 deny_unknown_fields,
171 expecting = "a table with 'name' and/or 'email' keys"
172)]
173pub enum Contact {
174 NameEmail { name: String, email: String },
176 Name { name: String },
178 Email { email: String },
180}
181
182impl Contact {
183 pub fn name(&self) -> Option<&str> {
185 match self {
186 Contact::NameEmail { name, .. } | Contact::Name { name } => Some(name),
187 Contact::Email { .. } => None,
188 }
189 }
190
191 pub fn email(&self) -> Option<&str> {
193 match self {
194 Contact::NameEmail { email, .. } | Contact::Email { email } => Some(email),
195 Contact::Name { .. } => None,
196 }
197 }
198}
199
200#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
202#[serde(transparent)]
203pub struct DependencyGroups(pub IndexMap<String, Vec<DependencyGroupSpecifier>>);
204
205impl Deref for DependencyGroups {
206 type Target = IndexMap<String, Vec<DependencyGroupSpecifier>>;
207
208 fn deref(&self) -> &Self::Target {
209 &self.0
210 }
211}
212
213#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
215#[serde(rename_all = "kebab-case", untagged)]
216#[allow(clippy::large_enum_variant)]
217pub enum DependencyGroupSpecifier {
218 String(Requirement),
220 #[serde(rename_all = "kebab-case")]
222 Table {
223 include_group: String,
225 },
226}
227
228impl PyProjectToml {
229 pub fn new(content: &str) -> Result<Self, toml::de::Error> {
231 toml::de::from_str(content)
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::{DependencyGroupSpecifier, License, PyProjectToml, ReadMe};
238 use pep440_rs::{Version, VersionSpecifiers};
239 use pep508_rs::Requirement;
240 use std::path::PathBuf;
241 use std::str::FromStr;
242
243 #[test]
244 fn test_parse_pyproject_toml() {
245 let source = r#"[build-system]
246requires = ["maturin"]
247build-backend = "maturin"
248
249[project]
250name = "spam"
251version = "2020.0.0"
252description = "Lovely Spam! Wonderful Spam!"
253readme = "README.rst"
254requires-python = ">=3.8"
255license = {file = "LICENSE.txt"}
256keywords = ["egg", "bacon", "sausage", "tomatoes", "Lobster Thermidor"]
257authors = [
258 {email = "hi@pradyunsg.me"},
259 {name = "Tzu-Ping Chung"}
260]
261maintainers = [
262 {name = "Brett Cannon", email = "brett@python.org"}
263]
264classifiers = [
265 "Development Status :: 4 - Beta",
266 "Programming Language :: Python"
267]
268
269dependencies = [
270 "httpx",
271 "gidgethub[httpx]>4.0.0",
272 "django>2.1; os_name != 'nt'",
273 "django>2.0; os_name == 'nt'"
274]
275
276[project.optional-dependencies]
277test = [
278 "pytest < 5.0.0",
279 "pytest-cov[all]"
280]
281
282[project.urls]
283homepage = "example.com"
284documentation = "readthedocs.org"
285repository = "github.com"
286changelog = "github.com/me/spam/blob/master/CHANGELOG.md"
287
288[project.scripts]
289spam-cli = "spam:main_cli"
290
291[project.gui-scripts]
292spam-gui = "spam:main_gui"
293
294[project.entry-points."spam.magical"]
295tomatoes = "spam:main_tomatoes""#;
296 let project_toml = PyProjectToml::new(source).unwrap();
297 let build_system = &project_toml.build_system.unwrap();
298 assert_eq!(
299 build_system.requires,
300 &[Requirement::from_str("maturin").unwrap()]
301 );
302 assert_eq!(build_system.build_backend.as_deref(), Some("maturin"));
303
304 let project = project_toml.project.as_ref().unwrap();
305 assert_eq!(project.name, "spam");
306 assert_eq!(
307 project.version,
308 Some(Version::from_str("2020.0.0").unwrap())
309 );
310 assert_eq!(
311 project.description.as_deref(),
312 Some("Lovely Spam! Wonderful Spam!")
313 );
314 assert_eq!(
315 project.readme,
316 Some(ReadMe::RelativePath("README.rst".to_string()))
317 );
318 assert_eq!(
319 project.requires_python,
320 Some(VersionSpecifiers::from_str(">=3.8").unwrap())
321 );
322 assert_eq!(
323 project.license,
324 Some(License::File {
325 file: PathBuf::from("LICENSE.txt"),
326 })
327 );
328 assert_eq!(
329 project.keywords.as_ref().unwrap(),
330 &["egg", "bacon", "sausage", "tomatoes", "Lobster Thermidor"]
331 );
332 assert_eq!(
333 project.scripts.as_ref().unwrap()["spam-cli"],
334 "spam:main_cli"
335 );
336 assert_eq!(
337 project.gui_scripts.as_ref().unwrap()["spam-gui"],
338 "spam:main_gui"
339 );
340 }
341
342 #[test]
343 fn test_parse_pyproject_toml_license_expression() {
344 let source = r#"[build-system]
345requires = ["maturin"]
346build-backend = "maturin"
347
348[project]
349name = "spam"
350license = "MIT OR BSD-3-Clause"
351"#;
352 let project_toml = PyProjectToml::new(source).unwrap();
353 let project = project_toml.project.as_ref().unwrap();
354 assert_eq!(
355 project.license,
356 Some(License::Spdx("MIT OR BSD-3-Clause".to_owned()))
357 );
358 }
359
360 #[test]
362 fn test_parse_pyproject_toml_license_paths() {
363 let source = r#"[build-system]
364requires = ["maturin"]
365build-backend = "maturin"
366
367[project]
368name = "spam"
369license = "MIT AND (Apache-2.0 OR BSD-2-Clause)"
370license-files = [
371 "LICENSE",
372 "setuptools/_vendor/LICENSE",
373 "setuptools/_vendor/LICENSE.APACHE",
374 "setuptools/_vendor/LICENSE.BSD",
375]
376"#;
377 let project_toml = PyProjectToml::new(source).unwrap();
378 let project = project_toml.project.as_ref().unwrap();
379
380 assert_eq!(
381 project.license,
382 Some(License::Spdx(
383 "MIT AND (Apache-2.0 OR BSD-2-Clause)".to_owned()
384 ))
385 );
386 assert_eq!(
387 project.license_files,
388 Some(vec![
389 "LICENSE".to_owned(),
390 "setuptools/_vendor/LICENSE".to_owned(),
391 "setuptools/_vendor/LICENSE.APACHE".to_owned(),
392 "setuptools/_vendor/LICENSE.BSD".to_owned()
393 ])
394 );
395 }
396
397 #[test]
399 fn test_parse_pyproject_toml_license_globs() {
400 let source = r#"[build-system]
401requires = ["maturin"]
402build-backend = "maturin"
403
404[project]
405name = "spam"
406license = "MIT AND (Apache-2.0 OR BSD-2-Clause)"
407license-files = [
408 "LICENSE*",
409 "setuptools/_vendor/LICENSE*",
410]
411"#;
412 let project_toml = PyProjectToml::new(source).unwrap();
413 let project = project_toml.project.as_ref().unwrap();
414
415 assert_eq!(
416 project.license,
417 Some(License::Spdx(
418 "MIT AND (Apache-2.0 OR BSD-2-Clause)".to_owned()
419 ))
420 );
421 assert_eq!(
422 project.license_files,
423 Some(vec![
424 "LICENSE*".to_owned(),
425 "setuptools/_vendor/LICENSE*".to_owned(),
426 ])
427 );
428 }
429
430 #[test]
431 fn test_parse_pyproject_toml_default_license_files() {
432 let source = r#"[build-system]
433requires = ["maturin"]
434build-backend = "maturin"
435
436[project]
437name = "spam"
438"#;
439 let project_toml = PyProjectToml::new(source).unwrap();
440 let project = project_toml.project.as_ref().unwrap();
441
442 assert_eq!(project.license_files.clone(), None);
444 }
445
446 #[test]
447 fn test_parse_pyproject_toml_readme_content_type() {
448 let source = r#"[build-system]
449requires = ["maturin"]
450build-backend = "maturin"
451
452[project]
453name = "spam"
454readme = {text = "ReadMe!", content-type = "text/plain"}
455"#;
456 let project_toml = PyProjectToml::new(source).unwrap();
457 let project = project_toml.project.as_ref().unwrap();
458
459 assert_eq!(
460 project.readme,
461 Some(ReadMe::Table {
462 file: None,
463 text: Some("ReadMe!".to_string()),
464 content_type: Some("text/plain".to_string())
465 })
466 );
467 }
468
469 #[test]
470 fn test_parse_pyproject_toml_dependency_groups() {
471 let source = r#"[dependency-groups]
472alpha = ["beta", "gamma", "delta"]
473epsilon = ["eta<2.0", "theta==2024.09.01"]
474iota = [{include-group = "alpha"}]
475"#;
476 let project_toml = PyProjectToml::new(source).unwrap();
477 let dependency_groups = project_toml.dependency_groups.as_ref().unwrap();
478
479 assert_eq!(
480 dependency_groups["alpha"],
481 vec![
482 DependencyGroupSpecifier::String(Requirement::from_str("beta").unwrap()),
483 DependencyGroupSpecifier::String(Requirement::from_str("gamma").unwrap()),
484 DependencyGroupSpecifier::String(Requirement::from_str("delta").unwrap(),)
485 ]
486 );
487 assert_eq!(
488 dependency_groups["epsilon"],
489 vec![
490 DependencyGroupSpecifier::String(Requirement::from_str("eta<2.0").unwrap()),
491 DependencyGroupSpecifier::String(
492 Requirement::from_str("theta==2024.09.01").unwrap()
493 )
494 ]
495 );
496 assert_eq!(
497 dependency_groups["iota"],
498 vec![DependencyGroupSpecifier::Table {
499 include_group: "alpha".to_string()
500 }]
501 );
502 }
503
504 #[test]
505 fn invalid_email() {
506 let source = r#"
507[project]
508name = "hello-world"
509version = "0.1.0"
510# Ensure that the spans from toml handle utf-8 correctly
511authors = [
512 { name = "Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘", email = 1 }
513]
514"#;
515 let err = PyProjectToml::new(source).unwrap_err();
516 assert_eq!(
517 err.to_string(),
518 "TOML parse error at line 6, column 11
519 |
5206 | authors = [
521 | ^
522a table with 'name' and/or 'email' keys
523"
524 );
525 }
526
527 #[test]
528 fn test_contact_accessors() {
529 let contact = super::Contact::NameEmail {
530 name: "John Doe".to_string(),
531 email: "john@example.com".to_string(),
532 };
533
534 assert_eq!(contact.name(), Some("John Doe"));
535 assert_eq!(contact.email(), Some("john@example.com"));
536
537 let contact = super::Contact::Name {
538 name: "John Doe".to_string(),
539 };
540
541 assert_eq!(contact.name(), Some("John Doe"));
542 assert_eq!(contact.email(), None);
543
544 let contact = super::Contact::Email {
545 email: "john@example.com".to_string(),
546 };
547
548 assert_eq!(contact.name(), None);
549 assert_eq!(contact.email(), Some("john@example.com"));
550 }
551}