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