1 use crate::{CargoToml, PyProjectToml};
2 use anyhow::{bail, Context, Result};
3 use fs_err as fs;
4 use regex::Regex;
5 use serde::{Deserialize, Serialize};
6 use std::collections::HashMap;
7 use std::path::{Path, PathBuf};
8 use std::str;
9 
10 /// The metadata required to generate the .dist-info directory
11 #[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)]
12 pub struct WheelMetadata {
13     /// Python Package Metadata 2.1
14     pub metadata21: Metadata21,
15     /// The `[console_scripts]` for the entry_points.txt
16     pub scripts: HashMap<String, String>,
17     /// The name of the module can be distinct from the package name, mostly
18     /// because package names normally contain minuses while module names
19     /// have underscores. The package name is part of metadata21
20     pub module_name: String,
21 }
22 
23 /// Python Package Metadata 2.1 as specified in
24 /// https://packaging.python.org/specifications/core-metadata/
25 #[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)]
26 #[serde(rename_all = "kebab-case")]
27 #[allow(missing_docs)]
28 pub struct Metadata21 {
29     // Mandatory fields
30     pub metadata_version: String,
31     pub name: String,
32     pub version: String,
33     // Optional fields
34     pub platform: Vec<String>,
35     pub supported_platform: Vec<String>,
36     pub summary: Option<String>,
37     pub description: Option<String>,
38     pub description_content_type: Option<String>,
39     pub keywords: Option<String>,
40     pub home_page: Option<String>,
41     pub download_url: Option<String>,
42     pub author: Option<String>,
43     pub author_email: Option<String>,
44     pub maintainer: Option<String>,
45     pub maintainer_email: Option<String>,
46     pub license: Option<String>,
47     pub classifiers: Vec<String>,
48     pub requires_dist: Vec<String>,
49     pub provides_dist: Vec<String>,
50     pub obsoletes_dist: Vec<String>,
51     pub requires_python: Option<String>,
52     pub requires_external: Vec<String>,
53     pub project_url: HashMap<String, String>,
54     pub provides_extra: Vec<String>,
55     pub scripts: HashMap<String, String>,
56     pub gui_scripts: HashMap<String, String>,
57     pub entry_points: HashMap<String, HashMap<String, String>>,
58 }
59 
60 const PLAINTEXT_CONTENT_TYPE: &str = "text/plain; charset=UTF-8";
61 const GFM_CONTENT_TYPE: &str = "text/markdown; charset=UTF-8; variant=GFM";
62 
63 /// Guess a Description-Content-Type based on the file extension,
64 /// defaulting to plaintext if extension is unknown or empty.
65 ///
66 /// See https://packaging.python.org/specifications/core-metadata/#description-content-type
path_to_content_type(path: &Path) -> String67 fn path_to_content_type(path: &Path) -> String {
68     path.extension()
69         .map_or(String::from(PLAINTEXT_CONTENT_TYPE), |ext| {
70             let ext = ext.to_string_lossy().to_lowercase();
71             let type_str = match ext.as_str() {
72                 "rst" => "text/x-rst; charset=UTF-8",
73                 "md" => GFM_CONTENT_TYPE,
74                 "markdown" => GFM_CONTENT_TYPE,
75                 _ => PLAINTEXT_CONTENT_TYPE,
76             };
77             String::from(type_str)
78         })
79 }
80 
81 impl Metadata21 {
82     /// Merge metadata with pyproject.toml, where pyproject.toml takes precedence
83     ///
84     /// manifest_path must be the directory, not the file
merge_pyproject_toml(&mut self, manifest_path: impl AsRef<Path>) -> Result<()>85     fn merge_pyproject_toml(&mut self, manifest_path: impl AsRef<Path>) -> Result<()> {
86         let manifest_path = manifest_path.as_ref();
87         if !manifest_path.join("pyproject.toml").is_file() {
88             return Ok(());
89         }
90         let pyproject_toml =
91             PyProjectToml::new(manifest_path).context("pyproject.toml is invalid")?;
92         if let Some(project) = &pyproject_toml.project {
93             self.name = project.name.clone();
94 
95             if let Some(version) = &project.version {
96                 self.version = version.clone();
97             }
98 
99             if let Some(description) = &project.description {
100                 self.summary = Some(description.clone());
101             }
102 
103             match &project.readme {
104                 Some(pyproject_toml::ReadMe::RelativePath(readme_path)) => {
105                     let readme_path = manifest_path.join(readme_path);
106                     let description = Some(fs::read_to_string(&readme_path).context(format!(
107                         "Failed to read readme specified in pyproject.toml, which should be at {}",
108                         readme_path.display()
109                     ))?);
110                     self.description = description;
111                     self.description_content_type = Some(path_to_content_type(&readme_path));
112                 }
113                 Some(pyproject_toml::ReadMe::Table {
114                     file,
115                     text,
116                     content_type,
117                 }) => {
118                     if file.is_some() && text.is_some() {
119                         bail!("file and text fields of 'project.readme' are mutually-exclusive, only one of them should be specified");
120                     }
121                     if let Some(readme_path) = file {
122                         let readme_path = manifest_path.join(readme_path);
123                         let description = Some(fs::read_to_string(&readme_path).context(format!(
124                                 "Failed to read readme specified in pyproject.toml, which should be at {}",
125                                 readme_path.display()
126                             ))?);
127                         self.description = description;
128                     }
129                     if let Some(description) = text {
130                         self.description = Some(description.clone());
131                     }
132                     self.description_content_type = content_type.clone();
133                 }
134                 None => {}
135             }
136 
137             if let Some(requires_python) = &project.requires_python {
138                 self.requires_python = Some(requires_python.clone());
139             }
140 
141             if let Some(pyproject_toml::License { file, text }) = &project.license {
142                 if file.is_some() && text.is_some() {
143                     bail!("file and text fields of 'project.license' are mutually-exclusive, only one of them should be specified");
144                 }
145                 if let Some(license_path) = file {
146                     let license_path = manifest_path.join(license_path);
147                     self.license = Some(fs::read_to_string(&license_path).context(format!(
148                             "Failed to read license file specified in pyproject.toml, which should be at {}",
149                             license_path.display()
150                         ))?);
151                 }
152                 if let Some(license_text) = text {
153                     self.license = Some(license_text.clone());
154                 }
155             }
156 
157             if let Some(authors) = &project.authors {
158                 let mut names = Vec::with_capacity(authors.len());
159                 let mut emails = Vec::with_capacity(authors.len());
160                 for author in authors {
161                     match (&author.name, &author.email) {
162                         (Some(name), Some(email)) => {
163                             emails.push(format!("{} <{}>", name, email));
164                         }
165                         (Some(name), None) => {
166                             names.push(name.as_str());
167                         }
168                         (None, Some(email)) => {
169                             emails.push(email.clone());
170                         }
171                         (None, None) => {}
172                     }
173                 }
174                 if !names.is_empty() {
175                     self.author = Some(names.join(", "));
176                 }
177                 if !emails.is_empty() {
178                     self.author_email = Some(emails.join(", "));
179                 }
180             }
181 
182             if let Some(maintainers) = &project.maintainers {
183                 let mut names = Vec::with_capacity(maintainers.len());
184                 let mut emails = Vec::with_capacity(maintainers.len());
185                 for maintainer in maintainers {
186                     match (&maintainer.name, &maintainer.email) {
187                         (Some(name), Some(email)) => {
188                             emails.push(format!("{} <{}>", name, email));
189                         }
190                         (Some(name), None) => {
191                             names.push(name.as_str());
192                         }
193                         (None, Some(email)) => {
194                             emails.push(email.clone());
195                         }
196                         (None, None) => {}
197                     }
198                 }
199                 if !names.is_empty() {
200                     self.maintainer = Some(names.join(", "));
201                 }
202                 if !emails.is_empty() {
203                     self.maintainer_email = Some(emails.join(", "));
204                 }
205             }
206 
207             if let Some(keywords) = &project.keywords {
208                 self.keywords = Some(keywords.join(","));
209             }
210 
211             if let Some(classifiers) = &project.classifiers {
212                 self.classifiers = classifiers.clone();
213             }
214 
215             if let Some(urls) = &project.urls {
216                 self.project_url = urls.clone();
217             }
218 
219             if let Some(dependencies) = &project.dependencies {
220                 self.requires_dist = dependencies.clone();
221             }
222 
223             if let Some(dependencies) = &project.optional_dependencies {
224                 for (extra, deps) in dependencies {
225                     self.provides_extra.push(extra.clone());
226                     for dep in deps {
227                         let dist = if let Some((dep, marker)) = dep.split_once(';') {
228                             // optional dependency already has environment markers
229                             let new_marker =
230                                 format!("({}) and extra == '{}'", marker.trim(), extra);
231                             format!("{}; {}", dep, new_marker)
232                         } else {
233                             format!("{}; extra == '{}'", dep, extra)
234                         };
235                         self.requires_dist.push(dist);
236                     }
237                 }
238             }
239 
240             if let Some(scripts) = &project.scripts {
241                 self.scripts = scripts.clone();
242             }
243             if let Some(gui_scripts) = &project.gui_scripts {
244                 self.gui_scripts = gui_scripts.clone();
245             }
246             if let Some(entry_points) = &project.entry_points {
247                 // Raise error on ambiguous entry points: https://www.python.org/dev/peps/pep-0621/#entry-points
248                 if entry_points.contains_key("console_scripts") {
249                     bail!("console_scripts is not allowed in project.entry-points table");
250                 }
251                 if entry_points.contains_key("gui_scripts") {
252                     bail!("gui_scripts is not allowed in project.entry-points table");
253                 }
254                 self.entry_points = entry_points.clone();
255             }
256         }
257         Ok(())
258     }
259 
260     /// Uses a Cargo.toml to create the metadata for python packages
261     ///
262     /// manifest_path must be the directory, not the file
from_cargo_toml( cargo_toml: &CargoToml, manifest_path: impl AsRef<Path>, ) -> Result<Metadata21>263     pub fn from_cargo_toml(
264         cargo_toml: &CargoToml,
265         manifest_path: impl AsRef<Path>,
266     ) -> Result<Metadata21> {
267         let authors = cargo_toml
268             .package
269             .authors
270             .as_ref()
271             .map(|authors| authors.join(", "));
272 
273         let classifiers = cargo_toml.classifiers();
274 
275         let author_email = authors.as_ref().and_then(|authors| {
276             if authors.contains('@') {
277                 Some(authors.clone())
278             } else {
279                 None
280             }
281         });
282 
283         let extra_metadata = cargo_toml.remaining_core_metadata();
284 
285         let description: Option<String>;
286         let description_content_type: Option<String>;
287         // See https://packaging.python.org/specifications/core-metadata/#description
288         if let Some(ref readme) = cargo_toml.package.readme {
289             let readme_path = manifest_path.as_ref().join(readme);
290             description = Some(fs::read_to_string(&readme_path).context(format!(
291                 "Failed to read readme specified in Cargo.toml, which should be at {}",
292                 readme_path.display()
293             ))?);
294 
295             description_content_type = extra_metadata
296                 .description_content_type
297                 .or_else(|| Some(path_to_content_type(&readme_path)));
298         } else {
299             description = None;
300             description_content_type = None;
301         };
302         let name = extra_metadata
303             .name
304             .map(|name| {
305                 if let Some(pos) = name.find('.') {
306                     name.split_at(pos).0.to_string()
307                 } else {
308                     name.clone()
309                 }
310             })
311             .unwrap_or_else(|| cargo_toml.package.name.clone());
312         let mut project_url = extra_metadata.project_url.unwrap_or_default();
313         if let Some(repository) = cargo_toml.package.repository.as_ref() {
314             project_url.insert("Source Code".to_string(), repository.clone());
315         }
316 
317         let mut metadata = Metadata21 {
318             metadata_version: "2.1".to_owned(),
319 
320             // Mapped from cargo metadata
321             name,
322             version: cargo_toml.package.version.clone(),
323             summary: cargo_toml.package.description.clone(),
324             description,
325             description_content_type,
326             keywords: cargo_toml
327                 .package
328                 .keywords
329                 .clone()
330                 .map(|keywords| keywords.join(",")),
331             home_page: cargo_toml.package.homepage.clone(),
332             download_url: None,
333             // Cargo.toml has no distinction between author and author email
334             author: authors,
335             author_email,
336             license: cargo_toml.package.license.clone(),
337 
338             // Values provided through `[project.metadata.maturin]`
339             classifiers,
340             maintainer: extra_metadata.maintainer,
341             maintainer_email: extra_metadata.maintainer_email,
342             requires_dist: extra_metadata.requires_dist.unwrap_or_default(),
343             requires_python: extra_metadata.requires_python,
344             requires_external: extra_metadata.requires_external.unwrap_or_default(),
345             project_url,
346             provides_extra: extra_metadata.provides_extra.unwrap_or_default(),
347 
348             // Officially rarely used, and afaik not applicable with pyo3
349             provides_dist: Vec::new(),
350             obsoletes_dist: Vec::new(),
351 
352             // Open question: Should those also be supported? And if so, how?
353             platform: Vec::new(),
354             supported_platform: Vec::new(),
355             scripts: cargo_toml.scripts(),
356             gui_scripts: HashMap::new(),
357             entry_points: HashMap::new(),
358         };
359 
360         let manifest_path = manifest_path.as_ref();
361         metadata.merge_pyproject_toml(manifest_path)?;
362         Ok(metadata)
363     }
364 
365     /// Formats the metadata into a list where keys with multiple values
366     /// become multiple single-valued key-value pairs. This format is needed for the pypi
367     /// uploader and for the METADATA file inside wheels
to_vec(&self) -> Vec<(String, String)>368     pub fn to_vec(&self) -> Vec<(String, String)> {
369         let mut fields = vec![
370             ("Metadata-Version", self.metadata_version.clone()),
371             ("Name", self.name.clone()),
372             ("Version", self.get_version_escaped()),
373         ];
374 
375         let mut add_vec = |name, values: &[String]| {
376             for i in values {
377                 fields.push((name, i.clone()));
378             }
379         };
380 
381         add_vec("Platform", &self.platform);
382         add_vec("Supported-Platform", &self.supported_platform);
383         add_vec("Classifier", &self.classifiers);
384         add_vec("Requires-Dist", &self.requires_dist);
385         add_vec("Provides-Dist", &self.provides_dist);
386         add_vec("Obsoletes-Dist", &self.obsoletes_dist);
387         add_vec("Requires-External", &self.requires_external);
388         add_vec("Provides-Extra", &self.provides_extra);
389 
390         let mut add_option = |name, value: &Option<String>| {
391             if let Some(some) = value.clone() {
392                 fields.push((name, some));
393             }
394         };
395 
396         add_option("Summary", &self.summary.as_deref().map(fold_header));
397         add_option("Keywords", &self.keywords);
398         add_option("Home-Page", &self.home_page);
399         add_option("Download-URL", &self.download_url);
400         add_option("Author", &self.author);
401         add_option("Author-email", &self.author_email);
402         add_option("Maintainer", &self.maintainer);
403         add_option("Maintainer-email", &self.maintainer_email);
404         add_option("License", &self.license.as_deref().map(fold_header));
405         add_option("Requires-Python", &self.requires_python);
406         add_option("Description-Content-Type", &self.description_content_type);
407         // Project-URL is special
408         // "A string containing a browsable URL for the project and a label for it, separated by a comma."
409         // `Project-URL: Bug Tracker, http://bitbucket.org/tarek/distribute/issues/`
410         for (key, value) in self.project_url.iter() {
411             fields.push(("Project-URL", format!("{}, {}", key, value)))
412         }
413 
414         // Description shall be last, so we can ignore RFC822 and just put the description
415         // in the body
416         // The borrow checker doesn't like us using add_option here
417         if let Some(description) = &self.description {
418             fields.push(("Description", description.clone()));
419         }
420 
421         fields
422             .into_iter()
423             .map(|(k, v)| (k.to_string(), v))
424             .collect()
425     }
426 
427     /// Writes the format for the metadata file inside wheels
to_file_contents(&self) -> String428     pub fn to_file_contents(&self) -> String {
429         let mut fields = self.to_vec();
430         let mut out = "".to_string();
431         let body = match fields.last() {
432             Some((key, description)) if key == "Description" => {
433                 let desc = description.clone();
434                 fields.pop().unwrap();
435                 Some(desc)
436             }
437             Some((_, _)) => None,
438             None => None,
439         };
440 
441         for (key, value) in fields {
442             out += &format!("{}: {}\n", key, value);
443         }
444 
445         if let Some(body) = body {
446             out += &format!("\n{}\n", body);
447         }
448 
449         out
450     }
451 
452     /// Returns the distribution name according to PEP 427, Section "Escaping
453     /// and Unicode"
get_distribution_escaped(&self) -> String454     pub fn get_distribution_escaped(&self) -> String {
455         let re = Regex::new(r"[^\w\d.]+").unwrap();
456         re.replace_all(&self.name, "_").to_string()
457     }
458 
459     /// Returns the version encoded according to PEP 427, Section "Escaping
460     /// and Unicode"
get_version_escaped(&self) -> String461     pub fn get_version_escaped(&self) -> String {
462         let re = Regex::new(r"[^\w\d.]+").unwrap();
463         re.replace_all(&self.version, "_").to_string()
464     }
465 
466     /// Returns the name of the .dist-info directory as defined in the wheel specification
get_dist_info_dir(&self) -> PathBuf467     pub fn get_dist_info_dir(&self) -> PathBuf {
468         PathBuf::from(format!(
469             "{}-{}.dist-info",
470             &self.get_distribution_escaped(),
471             &self.get_version_escaped()
472         ))
473     }
474 }
475 
476 /// Fold long header field according to RFC 5322 section 2.2.3
477 /// https://datatracker.ietf.org/doc/html/rfc5322#section-2.2.3
fold_header(text: &str) -> String478 fn fold_header(text: &str) -> String {
479     let mut result = String::with_capacity(text.len());
480 
481     let options = textwrap::Options::new(78)
482         .initial_indent("")
483         .subsequent_indent("\t");
484     for (i, line) in textwrap::wrap(text, options).iter().enumerate() {
485         if i > 0 {
486             result.push_str("\r\n");
487         }
488         if line.is_empty() {
489             result.push('\t');
490         } else {
491             result.push_str(line);
492         }
493     }
494 
495     result
496 }
497 
498 #[cfg(test)]
499 mod test {
500     use super::*;
501     use indoc::indoc;
502     use std::io::Write;
503 
assert_metadata_from_cargo_toml(readme: &str, cargo_toml: &str, expected: &str)504     fn assert_metadata_from_cargo_toml(readme: &str, cargo_toml: &str, expected: &str) {
505         let mut readme_md = tempfile::NamedTempFile::new().unwrap();
506 
507         let readme_path = if cfg!(windows) {
508             readme_md.path().to_str().unwrap().replace("\\", "/")
509         } else {
510             readme_md.path().to_str().unwrap().to_string()
511         };
512 
513         readme_md.write_all(readme.as_bytes()).unwrap();
514 
515         let toml_with_path = cargo_toml.replace("REPLACE_README_PATH", &readme_path);
516 
517         let cargo_toml_struct: CargoToml = toml::from_str(&toml_with_path).unwrap();
518 
519         let metadata =
520             Metadata21::from_cargo_toml(&cargo_toml_struct, &readme_md.path().parent().unwrap())
521                 .unwrap();
522 
523         let actual = metadata.to_file_contents();
524 
525         assert_eq!(
526             actual.trim(),
527             expected.trim(),
528             "Actual metadata differed from expected\nEXPECTED:\n{}\n\nGOT:\n{}",
529             expected,
530             actual
531         );
532 
533         // get_dist_info_dir test checks against hard-coded values - check that they are as expected in the source first
534         assert!(
535             cargo_toml.contains("name = \"info-project\"")
536                 && cargo_toml.contains("version = \"0.1.0\""),
537             "cargo_toml name and version string do not match hardcoded values, test will fail",
538         );
539         assert_eq!(
540             metadata.get_dist_info_dir(),
541             PathBuf::from("info_project-0.1.0.dist-info"),
542             "Dist info dir differed from expected"
543         );
544     }
545 
546     #[test]
test_metadata_from_cargo_toml()547     fn test_metadata_from_cargo_toml() {
548         let readme = indoc!(
549             r#"
550             # Some test package
551 
552             This is the readme for a test package
553         "#
554         );
555 
556         let cargo_toml = indoc!(
557             r#"
558             [package]
559             authors = ["konstin <konstin@mailbox.org>"]
560             name = "info-project"
561             version = "0.1.0"
562             description = "A test project"
563             homepage = "https://example.org"
564             readme = "REPLACE_README_PATH"
565             keywords = ["ffi", "test"]
566 
567             [lib]
568             crate-type = ["cdylib"]
569             name = "pyo3_pure"
570 
571             [package.metadata.maturin.scripts]
572             ph = "maturin:print_hello"
573 
574             [package.metadata.maturin]
575             classifiers = ["Programming Language :: Python"]
576             requires-dist = ["flask~=1.1.0", "toml==0.10.0"]
577             project-url = { "Bug Tracker" = "http://bitbucket.org/tarek/distribute/issues/" }
578         "#
579         );
580 
581         let expected = indoc!(
582             r#"
583             Metadata-Version: 2.1
584             Name: info-project
585             Version: 0.1.0
586             Classifier: Programming Language :: Python
587             Requires-Dist: flask~=1.1.0
588             Requires-Dist: toml==0.10.0
589             Summary: A test project
590             Keywords: ffi,test
591             Home-Page: https://example.org
592             Author: konstin <konstin@mailbox.org>
593             Author-email: konstin <konstin@mailbox.org>
594             Description-Content-Type: text/plain; charset=UTF-8
595             Project-URL: Bug Tracker, http://bitbucket.org/tarek/distribute/issues/
596 
597             # Some test package
598 
599             This is the readme for a test package
600         "#
601         );
602 
603         assert_metadata_from_cargo_toml(readme, cargo_toml, expected);
604     }
605 
606     #[test]
test_metadata_from_cargo_toml_rst()607     fn test_metadata_from_cargo_toml_rst() {
608         let readme = indoc!(
609             r#"
610             Some test package
611             =================
612         "#
613         );
614 
615         let cargo_toml = indoc!(
616             r#"
617             [package]
618             authors = ["konstin <konstin@mailbox.org>"]
619             name = "info-project"
620             version = "0.1.0"
621             description = "A test project"
622             homepage = "https://example.org"
623             repository = "https://example.org"
624             readme = "REPLACE_README_PATH"
625             keywords = ["ffi", "test"]
626 
627             [lib]
628             crate-type = ["cdylib"]
629             name = "pyo3_pure"
630 
631             [package.metadata.maturin.scripts]
632             ph = "maturin:print_hello"
633 
634             [package.metadata.maturin]
635             classifiers = ["Programming Language :: Python"]
636             requires-dist = ["flask~=1.1.0", "toml==0.10.0"]
637             description-content-type = "text/x-rst"
638         "#
639         );
640 
641         let expected = indoc!(
642             r#"
643             Metadata-Version: 2.1
644             Name: info-project
645             Version: 0.1.0
646             Classifier: Programming Language :: Python
647             Requires-Dist: flask~=1.1.0
648             Requires-Dist: toml==0.10.0
649             Summary: A test project
650             Keywords: ffi,test
651             Home-Page: https://example.org
652             Author: konstin <konstin@mailbox.org>
653             Author-email: konstin <konstin@mailbox.org>
654             Description-Content-Type: text/x-rst
655             Project-URL: Source Code, https://example.org
656 
657             Some test package
658             =================
659         "#
660         );
661 
662         assert_metadata_from_cargo_toml(readme, cargo_toml, expected);
663     }
664 
665     #[test]
test_metadata_from_cargo_toml_name_override()666     fn test_metadata_from_cargo_toml_name_override() {
667         let cargo_toml = indoc!(
668             r#"
669             [package]
670             authors = ["konstin <konstin@mailbox.org>"]
671             name = "info-project"
672             version = "0.1.0"
673             description = "A test project"
674             homepage = "https://example.org"
675 
676             [lib]
677             crate-type = ["cdylib"]
678             name = "pyo3_pure"
679 
680             [package.metadata.maturin.scripts]
681             ph = "maturin:print_hello"
682 
683             [package.metadata.maturin]
684             name = "info"
685             classifiers = ["Programming Language :: Python"]
686             description-content-type = "text/x-rst"
687         "#
688         );
689 
690         let expected = indoc!(
691             r#"
692             Metadata-Version: 2.1
693             Name: info
694             Version: 0.1.0
695             Classifier: Programming Language :: Python
696             Summary: A test project
697             Home-Page: https://example.org
698             Author: konstin <konstin@mailbox.org>
699             Author-email: konstin <konstin@mailbox.org>
700         "#
701         );
702 
703         let cargo_toml_struct: CargoToml = toml::from_str(&cargo_toml).unwrap();
704         let metadata =
705             Metadata21::from_cargo_toml(&cargo_toml_struct, "/not/exist/manifest/path").unwrap();
706         let actual = metadata.to_file_contents();
707 
708         assert_eq!(
709             actual.trim(),
710             expected.trim(),
711             "Actual metadata differed from expected\nEXPECTED:\n{}\n\nGOT:\n{}",
712             expected,
713             actual
714         );
715 
716         assert_eq!(
717             metadata.get_dist_info_dir(),
718             PathBuf::from("info-0.1.0.dist-info"),
719             "Dist info dir differed from expected"
720         );
721     }
722 
723     #[test]
test_path_to_content_type()724     fn test_path_to_content_type() {
725         for (filename, expected) in &[
726             ("r.md", GFM_CONTENT_TYPE),
727             ("r.markdown", GFM_CONTENT_TYPE),
728             ("r.mArKdOwN", GFM_CONTENT_TYPE),
729             ("r.rst", "text/x-rst; charset=UTF-8"),
730             ("r.somethingelse", PLAINTEXT_CONTENT_TYPE),
731             ("r", PLAINTEXT_CONTENT_TYPE),
732         ] {
733             let result = path_to_content_type(&PathBuf::from(filename));
734             assert_eq!(
735                 &result.as_str(),
736                 expected,
737                 "Wrong content type for file '{}'. Expected '{}', got '{}'",
738                 filename,
739                 expected,
740                 result
741             );
742         }
743     }
744 
745     #[test]
test_merge_metadata_from_pyproject_toml()746     fn test_merge_metadata_from_pyproject_toml() {
747         let cargo_toml_str = fs_err::read_to_string("test-crates/pyo3-pure/Cargo.toml").unwrap();
748         let cargo_toml: CargoToml = toml::from_str(&cargo_toml_str).unwrap();
749         let metadata = Metadata21::from_cargo_toml(&cargo_toml, "test-crates/pyo3-pure").unwrap();
750         assert_eq!(
751             metadata.summary,
752             Some("Implements a dummy function in Rust".to_string())
753         );
754         assert_eq!(
755             metadata.description,
756             Some(fs_err::read_to_string("test-crates/pyo3-pure/Readme.md").unwrap())
757         );
758         assert_eq!(metadata.classifiers, &["Programming Language :: Rust"]);
759         assert_eq!(
760             metadata.maintainer_email,
761             Some("messense <messense@icloud.com>".to_string())
762         );
763         assert_eq!(metadata.scripts["get_42"], "pyo3_pure:DummyClass.get_42");
764         assert_eq!(
765             metadata.gui_scripts["get_42_gui"],
766             "pyo3_pure:DummyClass.get_42"
767         );
768         assert_eq!(metadata.provides_extra, &["test"]);
769         assert_eq!(
770             metadata.requires_dist,
771             &[
772                 "attrs; extra == 'test'",
773                 "boltons; (sys_platform == 'win32') and extra == 'test'"
774             ]
775         );
776 
777         let content = metadata.to_file_contents();
778         let pkginfo: Result<python_pkginfo::Metadata, _> = content.parse();
779         assert!(pkginfo.is_ok());
780     }
781 
782     #[test]
test_merge_metadata_from_pyproject_toml_with_customized_python_source_dir()783     fn test_merge_metadata_from_pyproject_toml_with_customized_python_source_dir() {
784         let cargo_toml_str =
785             fs_err::read_to_string("test-crates/pyo3-mixed-py-subdir/Cargo.toml").unwrap();
786         let cargo_toml: CargoToml = toml::from_str(&cargo_toml_str).unwrap();
787         let metadata =
788             Metadata21::from_cargo_toml(&cargo_toml, "test-crates/pyo3-mixed-py-subdir").unwrap();
789         // defined in Cargo.toml
790         assert_eq!(
791             metadata.summary,
792             Some("Implements a dummy function combining rust and python".to_string())
793         );
794         // defined in pyproject.toml
795         assert_eq!(metadata.scripts["get_42"], "pyo3_mixed_py_subdir:get_42");
796     }
797 }
798