1use crate::{
2 vcs, Certainty, GuesserSettings, Origin, Person, ProviderError, UpstreamDatum,
3 UpstreamDatumWithMetadata, UpstreamMetadata,
4};
5use log::{debug, warn};
6use serde::Deserialize;
7
8#[cfg(feature = "pyo3")]
9use pyo3::prelude::*;
10use std::collections::HashMap;
11use std::path::Path;
12
13#[cfg(feature = "python-pkginfo")]
14pub async fn guess_from_pkg_info(
16 path: &Path,
17 _settings: &GuesserSettings,
18) -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
19 let contents = std::fs::read(path)?;
20 let dist = python_pkginfo::Metadata::parse(contents.as_slice()).map_err(|e| {
21 ProviderError::ParseError(format!("Failed to parse python package metadata: {}", e))
22 })?;
23
24 let mut ret = vec![];
25
26 ret.push(UpstreamDatumWithMetadata {
27 datum: UpstreamDatum::Name(dist.name),
28 certainty: Some(Certainty::Certain),
29 origin: Some(path.into()),
30 });
31
32 ret.push(UpstreamDatumWithMetadata {
33 datum: UpstreamDatum::Version(dist.version),
34 certainty: Some(Certainty::Certain),
35 origin: Some(path.into()),
36 });
37
38 if let Some(homepage) = dist.home_page {
39 ret.push(UpstreamDatumWithMetadata {
40 datum: UpstreamDatum::Homepage(homepage),
41 certainty: Some(Certainty::Certain),
42 origin: Some(path.into()),
43 });
44 }
45
46 if let Some(summary) = dist.summary {
47 ret.push(UpstreamDatumWithMetadata {
48 datum: UpstreamDatum::Summary(summary),
49 certainty: Some(Certainty::Certain),
50 origin: Some(path.into()),
51 });
52 }
53
54 if let Some(description) = dist.description {
55 ret.extend(parse_python_long_description(
56 description.as_str(),
57 dist.description_content_type.as_deref(),
58 &Origin::Path(path.to_path_buf()),
59 )?);
60 }
61
62 ret.extend(parse_python_project_urls(
63 dist.project_urls
64 .iter()
65 .filter_map(|k| k.split_once(", "))
66 .map(|(k, v)| (k.to_string(), v.to_string())),
67 &Origin::Path(path.to_path_buf()),
68 ));
69
70 if dist.author.is_some() || dist.author_email.is_some() {
71 ret.push(UpstreamDatumWithMetadata {
72 datum: UpstreamDatum::Author(vec![Person {
73 name: dist.author,
74 email: dist.author_email,
75 url: None,
76 }]),
77
78 certainty: Some(Certainty::Certain),
79 origin: Some(path.into()),
80 });
81 }
82
83 if dist.maintainer.is_some() || dist.maintainer_email.is_some() {
84 ret.push(UpstreamDatumWithMetadata {
85 datum: UpstreamDatum::Maintainer(Person {
86 name: dist.maintainer,
87 email: dist.maintainer_email,
88 url: None,
89 }),
90 certainty: Some(Certainty::Certain),
91 origin: Some(path.into()),
92 });
93 }
94
95 if let Some(license) = dist.license {
96 ret.push(UpstreamDatumWithMetadata {
97 datum: UpstreamDatum::License(license),
98 certainty: Some(Certainty::Certain),
99 origin: Some(path.into()),
100 });
101 }
102
103 if let Some(keywords) = dist.keywords {
104 ret.push(UpstreamDatumWithMetadata {
105 datum: UpstreamDatum::Keywords(keywords.split(", ").map(|s| s.to_string()).collect()),
106 certainty: Some(Certainty::Certain),
107 origin: Some(path.into()),
108 });
109 }
110
111 if let Some(download_url) = dist.download_url {
112 ret.push(UpstreamDatumWithMetadata {
113 datum: UpstreamDatum::Download(download_url),
114 certainty: Some(Certainty::Certain),
115 origin: Some(path.into()),
116 });
117 }
118
119 Ok(ret)
120}
121
122#[cfg(feature = "pyproject-toml")]
124pub fn guess_from_pyproject_toml(
125 path: &Path,
126 _settings: &GuesserSettings,
127) -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
128 let content = std::fs::read_to_string(path)?;
129 let mut ret = Vec::new();
130
131 use serde::{Deserialize, Serialize};
132
133 #[derive(Serialize, Deserialize, Debug, Clone)]
134 pub struct PyProjectToml {
135 #[serde(flatten)]
136 inner: pyproject_toml::PyProjectToml,
137 tool: Option<Tool>,
138 }
139
140 #[derive(Serialize, Deserialize, Debug, Clone)]
141 #[serde(rename_all = "kebab-case")]
142 pub struct Tool {
143 poetry: Option<ToolPoetry>,
144 }
145
146 #[derive(Serialize, Deserialize, Debug, Clone)]
147 #[serde(rename_all = "kebab-case")]
148 pub struct ToolPoetry {
149 version: Option<String>,
150 description: Option<String>,
151 license: Option<String>,
152 repository: Option<String>,
153 name: String,
154 urls: Option<HashMap<String, String>>,
155 keywords: Option<Vec<String>>,
156 authors: Option<Vec<String>>,
157 homepage: Option<String>,
158 documentation: Option<String>,
159 }
160
161 let pyproject: PyProjectToml =
162 toml::from_str(content.as_str()).map_err(|e| ProviderError::ParseError(e.to_string()))?;
163
164 if let Some(inner_project) = pyproject.inner.project {
165 ret.push(UpstreamDatumWithMetadata {
166 datum: UpstreamDatum::Name(inner_project.name),
167 certainty: Some(Certainty::Certain),
168 origin: Some(path.into()),
169 });
170
171 if let Some(version) = inner_project.version {
172 ret.push(UpstreamDatumWithMetadata {
173 datum: UpstreamDatum::Version(version.to_string()),
174 certainty: Some(Certainty::Certain),
175 origin: Some(path.into()),
176 });
177 }
178
179 if let Some(description) = inner_project.description {
181 ret.push(UpstreamDatumWithMetadata {
182 datum: UpstreamDatum::Summary(description),
183 certainty: Some(Certainty::Certain),
184 origin: Some(path.into()),
185 });
186 }
187
188 if let Some(pyproject_toml::License::Spdx(license)) = inner_project.license.as_ref() {
189 ret.push(UpstreamDatumWithMetadata {
190 datum: UpstreamDatum::License(license.clone()),
191 certainty: Some(Certainty::Certain),
192 origin: Some(path.into()),
193 });
194 }
195
196 fn contact_to_person(contact: &pyproject_toml::Contact) -> Person {
197 Person {
198 name: contact.name().map(|s| s.to_string()),
199 email: contact.email().map(|s| s.to_string()),
200 url: None,
201 }
202 }
203
204 if let Some(authors) = inner_project.authors {
205 ret.push(UpstreamDatumWithMetadata {
206 datum: UpstreamDatum::Author(authors.iter().map(contact_to_person).collect()),
207 certainty: Some(Certainty::Certain),
208 origin: Some(path.into()),
209 });
210 }
211
212 if let Some(maintainers) = inner_project.maintainers {
213 let maintainers: Vec<_> = maintainers.iter().map(contact_to_person).collect();
214 let certainty = if maintainers.len() == 1 {
215 Certainty::Certain
216 } else {
217 Certainty::Possible
218 };
219 ret.push(UpstreamDatumWithMetadata {
220 datum: UpstreamDatum::Maintainer(maintainers[0].clone()),
221 certainty: Some(certainty),
222 origin: Some(path.into()),
223 });
224 }
225
226 if let Some(keywords) = inner_project.keywords {
227 ret.push(UpstreamDatumWithMetadata {
228 datum: UpstreamDatum::Keywords(keywords),
229 certainty: Some(Certainty::Certain),
230 origin: Some(path.into()),
231 });
232 }
233
234 if let Some(urls) = inner_project.urls {
235 ret.extend(parse_python_project_urls(
236 urls.into_iter(),
237 &Origin::Path(path.to_path_buf()),
238 ));
239 }
240
241 if let Some(classifiers) = inner_project.classifiers {
242 ret.extend(parse_python_classifiers(
243 classifiers.iter().map(|s| s.as_str()),
244 &Origin::Path(path.to_path_buf()),
245 ));
246 }
247 }
248
249 if let Some(tool) = pyproject.tool {
250 if let Some(poetry) = tool.poetry {
251 if let Some(version) = poetry.version {
252 ret.push(UpstreamDatumWithMetadata {
253 datum: UpstreamDatum::Version(version),
254 certainty: Some(Certainty::Certain),
255 origin: Some(path.into()),
256 });
257 }
258
259 if let Some(description) = poetry.description {
260 ret.push(UpstreamDatumWithMetadata {
261 datum: UpstreamDatum::Summary(description),
262 certainty: Some(Certainty::Certain),
263 origin: Some(path.into()),
264 });
265 }
266
267 if let Some(license) = poetry.license {
268 ret.push(UpstreamDatumWithMetadata {
269 datum: UpstreamDatum::License(license),
270 certainty: Some(Certainty::Certain),
271 origin: Some(path.into()),
272 });
273 }
274
275 if let Some(repository) = poetry.repository {
276 ret.push(UpstreamDatumWithMetadata {
277 datum: UpstreamDatum::Repository(repository),
278 certainty: Some(Certainty::Certain),
279 origin: Some(path.into()),
280 });
281 }
282
283 ret.push(UpstreamDatumWithMetadata {
284 datum: UpstreamDatum::Name(poetry.name.to_string()),
285 certainty: Some(Certainty::Certain),
286 origin: Some(path.into()),
287 });
288
289 if let Some(urls) = poetry.urls {
290 ret.extend(parse_python_project_urls(
291 urls.into_iter(),
292 &Origin::Path(path.to_path_buf()),
293 ));
294 }
295
296 if let Some(keywords) = poetry.keywords {
297 ret.push(UpstreamDatumWithMetadata {
298 datum: UpstreamDatum::Keywords(keywords),
299 certainty: Some(Certainty::Certain),
300 origin: Some(path.into()),
301 });
302 }
303
304 if let Some(authors) = poetry.authors {
305 ret.push(UpstreamDatumWithMetadata {
306 datum: UpstreamDatum::Author(
307 authors.iter().map(|p| Person::from(p.as_str())).collect(),
308 ),
309 certainty: Some(Certainty::Certain),
310 origin: Some(path.into()),
311 });
312 }
313
314 if let Some(homepage) = poetry.homepage {
315 ret.push(UpstreamDatumWithMetadata {
316 datum: UpstreamDatum::Homepage(homepage),
317 certainty: Some(Certainty::Certain),
318 origin: Some(path.into()),
319 });
320 }
321
322 if let Some(documentation) = poetry.documentation {
323 ret.push(UpstreamDatumWithMetadata {
324 datum: UpstreamDatum::Documentation(documentation),
325 certainty: Some(Certainty::Certain),
326 origin: Some(path.into()),
327 });
328 }
329 }
330 }
331
332 Ok(ret)
333}
334
335fn parse_python_project_urls(
336 urls: impl Iterator<Item = (String, String)>,
337 origin: &Origin,
338) -> Vec<UpstreamDatumWithMetadata> {
339 let mut ret = Vec::new();
340 for (url_type, url) in urls {
341 match url_type.as_str() {
342 "GitHub" | "Repository" | "Source Code" | "Source" => {
343 ret.push(UpstreamDatumWithMetadata {
344 datum: UpstreamDatum::Repository(url.to_string()),
345 certainty: Some(Certainty::Certain),
346 origin: Some(origin.clone()),
347 });
348 }
349 "Bug Tracker" | "Bug Reports" => {
350 ret.push(UpstreamDatumWithMetadata {
351 datum: UpstreamDatum::BugDatabase(url.to_string()),
352 certainty: Some(Certainty::Certain),
353 origin: Some(origin.clone()),
354 });
355 }
356 "Documentation" => {
357 ret.push(UpstreamDatumWithMetadata {
358 datum: UpstreamDatum::Documentation(url.to_string()),
359 certainty: Some(Certainty::Certain),
360 origin: Some(origin.clone()),
361 });
362 }
363 "Funding" => {
364 ret.push(UpstreamDatumWithMetadata {
365 datum: UpstreamDatum::Funding(url.to_string()),
366 certainty: Some(Certainty::Certain),
367 origin: Some(origin.clone()),
368 });
369 }
370 "Homepage" => {
371 ret.push(UpstreamDatumWithMetadata {
372 datum: UpstreamDatum::Homepage(url.to_string()),
373 certainty: Some(Certainty::Certain),
374 origin: Some(origin.clone()),
375 });
376 }
377 _u => {
378 debug!("Unknown Python project URL type: {}", url_type);
379 }
380 }
381 }
382 ret
383}
384
385fn parse_python_long_description(
386 long_description: &str,
387 content_type: Option<&str>,
388 origin: &Origin,
389) -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
390 if long_description.is_empty() {
391 return Ok(vec![]);
392 }
393 let content_type = content_type.unwrap_or("text/plain");
394 let mut content_type = content_type.split(';').next().unwrap();
395 if long_description.contains("-*-restructuredtext-*-") {
396 content_type = "text/restructured-text";
397 }
398
399 let mut ret = vec![];
400 match content_type {
401 "text/plain" => {
402 let lines = long_description.split('\n').collect::<Vec<_>>();
403 if lines.len() > 30 {
404 debug!("Long description is too long ({} lines)", lines.len());
405 return Ok(vec![]);
406 }
407 ret.push(UpstreamDatumWithMetadata {
408 datum: UpstreamDatum::Description(long_description.to_string()),
409 certainty: Some(Certainty::Possible),
410 origin: Some(origin.clone()),
411 });
412 }
413 "text/restructured-text" | "text/x-rst" => {
414 let (description, extra_md) =
415 crate::readme::description_from_readme_rst(long_description)
416 .map_err(|e| ProviderError::Other(e.to_string()))?;
417 if let Some(description) = description {
418 ret.push(UpstreamDatumWithMetadata {
419 datum: UpstreamDatum::Description(description),
420 certainty: Some(Certainty::Possible),
421 origin: Some(Origin::Other(
422 "python long description (restructuredText)".to_string(),
423 )),
424 });
425 }
426 ret.extend(extra_md);
427 }
428 "text/markdown" => {
429 let (description, extra_md) =
430 crate::readme::description_from_readme_md(long_description)
431 .map_err(|e| ProviderError::Other(e.to_string()))?;
432 if let Some(description) = description {
433 ret.push(UpstreamDatumWithMetadata {
434 datum: UpstreamDatum::Description(description),
435 certainty: Some(Certainty::Possible),
436 origin: Some(Origin::Other(
437 "python long description (markdown)".to_string(),
438 )),
439 });
440 }
441 ret.extend(extra_md);
442 }
443 _ => {
444 warn!("Unknown content type: {}", content_type);
445 }
446 }
447 Ok(ret)
448}
449
450pub async fn parse_python_url(url: &str) -> Vec<UpstreamDatumWithMetadata> {
452 let parsed_url = match url::Url::parse(url) {
453 Ok(url) => url,
454 Err(e) => {
455 log::warn!("Failed to parse Python URL {:?}: {}", url, e);
456 return vec![];
457 }
458 };
459 let repo = vcs::guess_repo_from_url(&parsed_url, None).await;
460 if let Some(repo) = repo {
461 return vec![UpstreamDatumWithMetadata {
462 datum: UpstreamDatum::Repository(repo),
463 certainty: Some(Certainty::Likely),
464 origin: None,
465 }];
466 }
467
468 vec![UpstreamDatumWithMetadata {
469 datum: UpstreamDatum::Homepage(url.to_string()),
470 certainty: Some(Certainty::Likely),
471 origin: None,
472 }]
473}
474
475#[cfg(feature = "setup-cfg")]
476pub async fn guess_from_setup_cfg(
478 path: &Path,
479 _settings: &GuesserSettings,
480) -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
481 let setup_cfg =
482 ini::Ini::load_from_file(path).map_err(|e| ProviderError::ParseError(e.to_string()))?;
483
484 let metadata = match setup_cfg.section(Some("metadata")) {
485 Some(metadata) => metadata,
486 None => {
487 debug!("No [metadata] section in setup.cfg");
488 return Ok(vec![]);
489 }
490 };
491
492 let origin = Origin::Path(path.to_path_buf());
493
494 let mut ret = vec![];
495
496 for (field, value) in metadata.iter() {
497 match field {
498 "name" => {
499 ret.push(UpstreamDatumWithMetadata {
500 datum: UpstreamDatum::Name(value.to_string()),
501 certainty: Some(Certainty::Certain),
502 origin: Some(origin.clone()),
503 });
504 }
505 "version" => {
506 if !value.starts_with("attr:") && !value.starts_with("file:") {
509 ret.push(UpstreamDatumWithMetadata {
510 datum: UpstreamDatum::Version(value.to_string()),
511 certainty: Some(Certainty::Certain),
512 origin: Some(origin.clone()),
513 });
514 }
515 }
516 "url" => {
517 ret.extend(parse_python_url(value).await);
518 }
519 "description" => {
520 ret.push(UpstreamDatumWithMetadata {
521 datum: UpstreamDatum::Summary(value.to_string()),
522 certainty: Some(Certainty::Certain),
523 origin: Some(origin.clone()),
524 });
525 }
526 "summary" => {
527 ret.push(UpstreamDatumWithMetadata {
528 datum: UpstreamDatum::Summary(value.to_string()),
529 certainty: Some(Certainty::Certain),
530 origin: Some(origin.clone()),
531 });
532 }
533 "long_description" => {
534 if let Some(path) = value.strip_prefix(value) {
535 if path.contains('/') {
536 debug!("Ignoring long_description path: {}", path);
537 continue;
538 }
539 let value = match std::fs::read_to_string(path) {
540 Ok(value) => value,
541 Err(e) => {
542 debug!("Failed to read long_description file: {}", e);
543 continue;
544 }
545 };
546 ret.extend(parse_python_long_description(
547 &value,
548 metadata.get("long_description_content_type"),
549 &origin,
550 )?);
551 } else {
552 ret.extend(parse_python_long_description(
553 value,
554 metadata.get("long_description_content_type"),
555 &origin,
556 )?);
557 }
558 }
559 "maintainer" => {
560 ret.push(UpstreamDatumWithMetadata {
561 datum: UpstreamDatum::Maintainer(Person {
562 name: Some(value.to_string()),
563 email: metadata
564 .get("maintainer_email")
565 .or_else(|| metadata.get("maintainer-email"))
566 .map(|s| s.to_string()),
567 url: None,
568 }),
569 certainty: Some(Certainty::Certain),
570 origin: Some(origin.clone()),
571 });
572 }
573 "author" => {
574 ret.push(UpstreamDatumWithMetadata {
575 datum: UpstreamDatum::Author(vec![Person {
576 name: Some(value.to_string()),
577 email: metadata
578 .get("author_email")
579 .or_else(|| metadata.get("author-email"))
580 .map(|s| s.to_string()),
581 url: None,
582 }]),
583 certainty: Some(Certainty::Certain),
584 origin: Some(origin.clone()),
585 });
586 }
587 "project_urls" => {
588 let urls = value.split('\n').filter_map(|s| {
589 if s.is_empty() {
590 return None;
591 }
592 let (key, value) = match s.split_once('=') {
593 Some((key, value)) => (key, value),
594 None => {
595 debug!("Invalid project_urls line: {}", s);
596 return None;
597 }
598 };
599 Some((key.to_string(), value.to_string()))
600 });
601 ret.extend(parse_python_project_urls(urls, &origin));
602 }
603 "license" => {
604 ret.push(UpstreamDatumWithMetadata {
605 datum: UpstreamDatum::License(value.to_string()),
606 certainty: Some(Certainty::Certain),
607 origin: Some(origin.clone()),
608 });
609 }
610 "home-page" => {
611 ret.push(UpstreamDatumWithMetadata {
612 datum: UpstreamDatum::Homepage(value.to_string()),
613 certainty: Some(Certainty::Certain),
614 origin: Some(origin.clone()),
615 });
616 }
617 "long_description_content_type"
618 | "maintainer_email"
619 | "author_email"
620 | "maintainer-email"
621 | "author-email" => {
622 }
624 _ => {
625 warn!("Unknown setup.cfg field: {}", field);
626 }
627 }
628 }
629
630 Ok(ret)
631}
632
633#[cfg(feature = "pyo3")]
634async fn guess_from_setup_py_executed(
635 path: &Path,
636) -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
637 static SETUP_PY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
639 let _guard = SETUP_PY_LOCK.lock().await;
640 let mut ret = Vec::new();
641 use pyo3::types::PyDict;
644 Python::initialize();
645 let mut long_description = None;
646 let mut urls: Vec<String> = vec![];
647 Python::attach(|py| {
648 let _ = py.import("setuptools");
649
650 let run_setup = py.import("distutils.core")?.getattr("run_setup")?;
651
652 let os = py.import("os")?;
653
654 let orig = match os.getattr("getcwd")?.call0() {
655 Ok(orig) => Some(orig.extract::<String>()?),
656 Err(e) => {
657 debug!("Failed to get current directory: {}", e);
658 None
659 }
660 };
661
662 let parent = path.parent().unwrap();
663
664 os.getattr("chdir")?.call1((parent,))?;
665
666 let result = || -> PyResult<_> {
667 let kwargs = PyDict::new(py);
668 kwargs.set_item("stop_after", "config")?;
669
670 run_setup.call((path,), Some(&kwargs))
671 }();
672
673 if let Some(orig) = orig {
674 os.getattr("chdir")?.call1((orig,))?;
675 }
676
677 let result = result?;
678
679 if let Some(name) = result.call_method0("get_name")?.extract()? {
680 ret.push(UpstreamDatumWithMetadata {
681 datum: UpstreamDatum::Name(name),
682 certainty: Some(Certainty::Certain),
683 origin: None,
684 });
685 }
686
687 if let Some(version) = result.call_method0("get_version")?.extract()? {
688 ret.push(UpstreamDatumWithMetadata {
689 datum: UpstreamDatum::Version(version),
690 certainty: Some(Certainty::Certain),
691 origin: None,
692 });
693 }
694
695 if let Some(url) = result
696 .call_method0("get_url")?
697 .extract::<Option<String>>()?
698 {
699 urls.push(url);
700 }
701
702 if let Some(download_url) = result.call_method0("get_download_url")?.extract()? {
703 ret.push(UpstreamDatumWithMetadata {
704 datum: UpstreamDatum::Download(download_url),
705 certainty: Some(Certainty::Certain),
706 origin: None,
707 });
708 }
709
710 if let Some(license) = result.call_method0("get_license")?.extract()? {
711 ret.push(UpstreamDatumWithMetadata {
712 datum: UpstreamDatum::License(license),
713 certainty: Some(Certainty::Likely),
714 origin: Some(Origin::Path(path.to_path_buf())),
715 });
716 }
717
718 if let Some(contact) = result.call_method0("get_contact")?.extract()? {
719 let contact: String = match result
720 .call_method0("get_contact_email")?
721 .extract::<Option<String>>()?
722 {
723 Some(email) => format!("{} <{}>", contact, email),
724 None => contact,
725 };
726 ret.push(UpstreamDatumWithMetadata {
727 datum: UpstreamDatum::Contact(contact),
728 certainty: Some(Certainty::Certain),
729 origin: Some(Origin::Path(path.to_path_buf())),
730 });
731 }
732
733 if let Some(description) = result.call_method0("get_description")?.extract()? {
734 ret.push(UpstreamDatumWithMetadata {
735 datum: UpstreamDatum::Summary(description),
736 certainty: Some(Certainty::Certain),
737 origin: Some(Origin::Path(path.to_path_buf())),
738 });
739 }
740
741 if let Some(description) = result
742 .call_method0("get_long_description")?
743 .extract::<Option<String>>()?
744 {
745 let content_type = match result.getattr("long_description_content_type") {
746 Ok(content_type) => content_type.extract::<Option<String>>(),
747 Err(e) if e.is_instance_of::<pyo3::exceptions::PyAttributeError>(py) => Ok(None),
748 Err(e) => return Err(e),
749 }?;
750 long_description = Some((description, content_type));
751 }
752
753 if let Ok(metadata) = result.getattr("metadata") {
754 if let Ok(project_urls) = metadata.getattr("project_urls") {
755 ret.extend(parse_python_project_urls(
756 project_urls
757 .extract::<HashMap<String, String>>()?
758 .into_iter(),
759 &Origin::Path(path.to_path_buf()),
760 ));
761 }
762 }
763 Ok::<(), PyErr>(())
764 })
765 .map_err(|e| {
766 warn!("Failed to run setup.py: {}", e);
767 ProviderError::Other(e.to_string())
768 })?;
769
770 if let Some((long_description, long_description_content_type)) = long_description {
771 ret.extend(parse_python_long_description(
772 long_description.as_str(),
773 long_description_content_type.as_deref(),
774 &Origin::Path(path.to_path_buf()),
775 )?);
776 }
777
778 for url in urls {
779 ret.extend(parse_python_url(&url).await);
780 }
781
782 Ok(ret)
783}
784
785#[cfg(feature = "pyo3")]
786pub async fn guess_from_setup_py(
788 path: &Path,
789 trust_package: bool,
790) -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
791 if trust_package {
792 guess_from_setup_py_executed(path).await
793 } else {
794 guess_from_setup_py_parsed(path).await
795 }
796}
797
798#[cfg(feature = "pyo3")]
799async fn guess_from_setup_py_parsed(
800 path: &Path,
801) -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
802 Python::initialize();
803 let code = match std::fs::read_to_string(path) {
804 Ok(setup_text) => setup_text,
805 Err(e) => {
806 warn!("Failed to read setup.py: {}", e);
807 return Err(ProviderError::IoError(e));
808 }
809 };
810
811 let mut long_description = None;
812 let mut ret = Vec::new();
813 let mut urls: Vec<String> = vec![];
814
815 Python::attach(|py| {
816 let ast = py.import("ast").unwrap();
817
818 let tree = ast.call_method1("parse", (code,))?;
825 let mut setup_args: HashMap<String, Py<PyAny>> = HashMap::new();
826
827 let ast_expr = ast.getattr("Expr").unwrap();
828 let ast_call = ast.getattr("Call").unwrap();
829 let ast_assign = ast.getattr("Assign").unwrap();
830 let ast_name = ast.getattr("Name").unwrap();
831
832 for statement in tree.getattr("body")?.try_iter()? {
833 let statement = statement?;
834 if (statement.is_instance(&ast_expr)?
837 || statement.is_instance(&ast_call)?
838 || statement.is_instance(&ast_assign)?)
839 && statement.getattr("value")?.is_instance(&ast_call)?
840 && statement
841 .getattr("value")?
842 .getattr("func")?
843 .is_instance(&ast_name)?
844 && (statement.getattr("value")?.getattr("func")?.getattr("id")?.extract::<String>()? == "setup" ||
845 statement.getattr("value")?.getattr("func")?.getattr("id")?.extract::<String>()? == "main")
848 {
849 let value = statement.getattr("value")?;
850
851 for kw in value.getattr("keywords")?.try_iter()? {
853 let kw = kw?;
854 let arg_name = kw.getattr("arg")?.extract::<String>()?;
855
856 setup_args.insert(arg_name, kw.getattr("value")?.unbind());
857 }
858 }
859 }
860
861 let ast_constant = ast.getattr("Constant").unwrap();
864
865 let get_str_from_expr = |expr: &Bound<PyAny>| -> Option<String> {
866 if expr.is_instance(&ast_constant).ok()? {
867 Some(expr.getattr("value").ok()?.extract::<String>().ok()?)
868 } else {
869 None
870 }
871 };
872
873 let ast_list = ast.getattr("List").unwrap();
874 let ast_tuple = ast.getattr("Tuple").unwrap();
875 let ast_set = ast.getattr("Set").unwrap();
876
877 let get_str_list_from_expr = |expr: &Bound<PyAny>| -> Option<Vec<String>> {
878 if expr.is_instance(&ast_list).ok()?
881 || expr.is_instance(&ast_tuple).ok()?
882 || expr.is_instance(&ast_set).ok()?
883 {
884 let mut ret = Vec::new();
885 for elt in expr.getattr("elts").ok()?.try_iter().ok()? {
886 let elt = elt.ok()?;
887 if let Some(value) = get_str_from_expr(&elt) {
888 ret.push(value);
889 } else {
890 return None;
891 }
892 }
893 Some(ret)
894 } else {
895 None
896 }
897 };
898
899 let ast = py.import("ast").unwrap();
900 let ast_dict = ast.getattr("Dict").unwrap();
901
902 let get_dict_from_expr = |expr: &Bound<PyAny>| -> Option<HashMap<String, String>> {
903 if expr.is_instance(&ast_dict).ok()? {
904 let mut ret = HashMap::new();
905 let keys = expr.getattr("keys").ok()?;
906 let values = expr.getattr("values").ok()?;
907 for (key, value) in keys.try_iter().ok()?.zip(values.try_iter().ok()?) {
908 if let Some(key) = get_str_from_expr(&key.ok()?) {
909 if let Some(value) = get_str_from_expr(&value.ok()?) {
910 ret.insert(key, value);
911 } else {
912 return None;
913 }
914 } else {
915 return None;
916 }
917 }
918 Some(ret)
919 } else {
920 None
921 }
922 };
923
924 for (key, value) in setup_args.iter() {
928 let value = value.bind(py);
929 match key.as_str() {
930 "name" => {
931 if let Some(name) = get_str_from_expr(value) {
932 ret.push(UpstreamDatumWithMetadata {
933 datum: UpstreamDatum::Name(name),
934 certainty: Some(Certainty::Certain),
935 origin: Some(path.into())
936 });
937 }
938 }
939 "version" => {
940 if let Some(version) = get_str_from_expr(value) {
941 ret.push(UpstreamDatumWithMetadata {
942 datum: UpstreamDatum::Version(version),
943 certainty: Some(Certainty::Certain),
944 origin: Some(path.into())
945 });
946 }
947 }
948 "description" => {
949 if let Some(description) = get_str_from_expr(value) {
950 ret.push(UpstreamDatumWithMetadata {
951 datum: UpstreamDatum::Summary(description),
952 certainty: Some(Certainty::Certain),
953 origin: Some(path.into())
954 });
955 }
956 }
957 "long_description" => {
958 if let Some(description) = get_str_from_expr(value) {
959 let content_type = setup_args.get("long_description_content_type");
960 let content_type = if let Some(content_type) = content_type {
961 get_str_from_expr(content_type.bind(py))
962 } else {
963 None
964 };
965 long_description = Some((description, content_type));
966 }
967 }
968 "license" => {
969 if let Some(license) = get_str_from_expr(value) {
970 ret.push(UpstreamDatumWithMetadata {
971 datum: UpstreamDatum::License(license),
972 certainty: Some(Certainty::Certain),
973 origin: Some(path.into()),
974 });
975 }
976 }
977 "download_url" => {
978 if let Some(download_url) = get_str_from_expr(value) {
979 ret.push(UpstreamDatumWithMetadata {
980 datum: UpstreamDatum::Download(download_url),
981 certainty: Some(Certainty::Certain),
982 origin: Some(path.into()),
983 });
984 }
985 }
986 "url" => {
987 if let Some(url) = get_str_from_expr(value) {
988 urls.push(url.clone());
989 }
990 }
991 "project_urls" => {
992 if let Some(project_urls) = get_dict_from_expr(value) {
993 ret.extend(parse_python_project_urls(project_urls.into_iter(), &Origin::Path(path.into())));
994 }
995 }
996 "maintainer" => {
997 if let Some(maintainer) = get_str_from_expr(value) {
998 let maintainer_email = setup_args.get("maintainer_email");
999 let maintainer_email = if let Some(maintainer_email) = maintainer_email {
1000 get_str_from_expr(maintainer_email.bind(py))
1001 } else {
1002 None
1003 };
1004 ret.push(UpstreamDatumWithMetadata {
1005 datum: UpstreamDatum::Maintainer(Person {
1006 name: Some(maintainer),
1007 email: maintainer_email,
1008 url: None
1009 }),
1010 certainty: Some(Certainty::Certain),
1011 origin: Some(path.into()),
1012 });
1013 }
1014 }
1015 "author" => {
1016 if let Some(author) = get_str_from_expr(value) {
1017 let author_email = setup_args.get("author_email");
1018 let author_email = if let Some(author_email) = author_email {
1019 get_str_from_expr(author_email.bind(py))
1020 } else {
1021 None
1022 };
1023 ret.push(UpstreamDatumWithMetadata {
1024 datum: UpstreamDatum::Author(vec![Person {
1025 name: Some(author),
1026 email: author_email,
1027 url: None
1028 }]),
1029 certainty: Some(Certainty::Certain),
1030 origin: Some(path.into()),
1031 });
1032 } else if let Some(author) = get_str_list_from_expr(value) {
1033 let author_emails = setup_args.get("author_email");
1034 let author_emails = if let Some(author_emails) = author_emails {
1035 get_str_list_from_expr(author_emails.bind(py)).map_or_else(|| vec![None; author.len()], |v| v.into_iter().map(Some).collect())
1036 } else {
1037 vec![None; author.len()]
1038 };
1039 let persons = author.into_iter().zip(author_emails.into_iter()).map(|(name, email)| Person {
1040 name: Some(name),
1041 email,
1042 url: None
1043 }).collect();
1044 ret.push(UpstreamDatumWithMetadata {
1045 datum: UpstreamDatum::Author(persons),
1046 certainty: Some(Certainty::Certain),
1047 origin: Some(path.into()),
1048 });
1049 }
1050 }
1051 "keywords" => {
1052 if let Some(keywords) = get_str_list_from_expr(value) {
1053 ret.push(UpstreamDatumWithMetadata {
1054 datum: UpstreamDatum::Keywords(keywords),
1055 certainty: Some(Certainty::Certain),
1056 origin: Some(path.into()),
1057 });
1058 }
1059 }
1060 "classifiers" => {
1061 if let Some(classifiers) = get_str_list_from_expr(value) {
1062 ret.extend(parse_python_classifiers(classifiers.iter().map(|s| s.as_str()), &Origin::Path(path.into())));
1063 }
1064 }
1065 "author_email" | "maintainer_email" => {},
1067 "rust_extensions" | "data_files" | "packages" | "package_dir" | "entry_points" | "package_data" => {},
1069 t if t.ends_with("_requires") || t.ends_with("_require") => {},
1071 _ => {
1072 warn!("Unknown key in setup.py: {}", key);
1073 }
1074 }
1075 }
1076 Ok::<(), PyErr>(())
1077 }).map_err(|e: PyErr| {
1078 Python::attach(|py| {
1079 if e.is_instance_of::<pyo3::exceptions::PySyntaxError>(py) {
1080 warn!("Syntax error while parsing setup.py: {}", e);
1081 ProviderError::Other(e.to_string())
1082 } else {
1083 warn!("Failed to parse setup.py: {}", e);
1084 ProviderError::Other(e.to_string())
1085 }
1086 })
1087 })?;
1088
1089 if let Some((description, content_type)) = long_description {
1090 ret.extend(parse_python_long_description(
1091 description.as_str(),
1092 content_type.as_deref(),
1093 &Origin::Path(path.into()),
1094 )?);
1095 }
1096
1097 for url in urls {
1098 ret.extend(parse_python_url(url.as_str()).await);
1099 }
1100
1101 Ok(ret)
1102}
1103
1104fn parse_python_classifiers<'a>(
1105 classifiers: impl Iterator<Item = &'a str> + 'a,
1106 origin: &'a Origin,
1107) -> impl Iterator<Item = UpstreamDatumWithMetadata> + 'a {
1108 classifiers.filter_map(|classifier| {
1109 let mut parts = classifier.split(" :: ");
1110 let category = parts.next()?;
1111 let subcategory = parts.next()?;
1112 let value = parts.next()?;
1113 let certainty = Some(Certainty::Certain);
1114 let origin = Some(origin.clone());
1115 match (category, subcategory) {
1116 ("Development Status", _) => None,
1117 ("Intended Audience", _) => None,
1118 ("License", "OSI Approved") => Some(UpstreamDatumWithMetadata {
1119 datum: UpstreamDatum::License(value.into()),
1120 certainty,
1121 origin,
1122 }),
1123 ("Natural Language", _) => None,
1124 ("Operating System", _) => None,
1125 ("Programming Language", _) => None,
1126 ("Topic", _) => None,
1127 _ => {
1128 warn!("Unknown classifier: {}", classifier);
1129 None
1130 }
1131 }
1132 })
1133}
1134
1135#[derive(Deserialize)]
1137pub struct PypiProjectInfo {
1138 pub author: Option<String>,
1140 pub author_email: Option<String>,
1142 pub bugtrack_url: Option<String>,
1144 pub classifiers: Vec<String>,
1146 pub description: String,
1148 pub description_content_type: Option<String>,
1150 pub docs_url: Option<String>,
1152 pub download_url: Option<String>,
1154 pub downloads: HashMap<String, isize>,
1156 pub dynamic: Option<bool>,
1158 pub home_page: Option<String>,
1160 pub keywords: Option<String>,
1162 pub license: Option<String>,
1164 pub maintainer: Option<String>,
1166 pub maintainer_email: Option<String>,
1168 pub name: String,
1170 pub package_url: String,
1172 pub platform: Option<String>,
1174 pub project_url: String,
1176 pub project_urls: Option<HashMap<String, String>>,
1178 pub provides_extra: Option<bool>,
1180 pub release_url: String,
1182 pub requires_dist: Option<Vec<String>>,
1184 pub requires_python: Option<String>,
1186 pub summary: String,
1188 pub version: String,
1190 pub yanked: Option<bool>,
1192 pub yanked_reason: Option<String>,
1194}
1195
1196#[derive(Deserialize)]
1198pub struct Digests {
1199 pub md5: String,
1201 pub sha256: String,
1203 pub blake2b_256: String,
1205}
1206
1207#[derive(Deserialize)]
1209pub struct PypiRelease {
1210 pub comment_text: String,
1212 pub digests: Digests,
1214 pub downloads: isize,
1216 pub filename: String,
1218 pub has_sig: bool,
1220 pub md5_digest: String,
1222 pub packagetype: String,
1224 pub python_version: String,
1226 pub requires_python: Option<String>,
1228 pub size: isize,
1230 pub upload_time: String,
1232 pub upload_time_iso_8601: String,
1234 pub url: String,
1236 pub yanked: bool,
1238 pub yanked_reason: Option<String>,
1240}
1241
1242#[derive(Deserialize)]
1244pub struct PypiUrl {
1245 pub comment_text: String,
1247 pub digests: Digests,
1249 pub filename: String,
1251 pub has_sig: bool,
1253 pub packagetype: String,
1255 pub python_version: String,
1257 pub requires_python: Option<String>,
1259 pub size: isize,
1261 pub upload_time: String,
1263 pub upload_time_iso_8601: String,
1265 pub url: String,
1267 pub yanked: bool,
1269 pub yanked_reason: Option<String>,
1271}
1272
1273#[derive(Deserialize)]
1275pub struct PypiProject {
1276 pub info: PypiProjectInfo,
1278 pub last_serial: isize,
1280 pub releases: HashMap<String, Vec<PypiRelease>>,
1282 pub urls: Vec<PypiUrl>,
1284 pub vulnerabilities: Vec<String>,
1286}
1287
1288impl TryInto<UpstreamMetadata> for PypiProject {
1289 type Error = ProviderError;
1290
1291 fn try_into(self) -> Result<UpstreamMetadata, Self::Error> {
1292 let mut metadata = UpstreamMetadata::default();
1293 if let Some(author) = self.info.author {
1294 metadata.insert(UpstreamDatumWithMetadata {
1295 datum: UpstreamDatum::Author(vec![Person {
1296 name: Some(author),
1297 email: self.info.author_email,
1298 url: None,
1299 }]),
1300 certainty: Some(Certainty::Certain),
1301 origin: None,
1302 });
1303 }
1304
1305 metadata.insert(UpstreamDatumWithMetadata {
1306 datum: UpstreamDatum::Description(self.info.description),
1307 certainty: Some(Certainty::Certain),
1308 origin: None,
1309 });
1310
1311 if let Some(homepage) = self.info.home_page {
1312 metadata.insert(UpstreamDatumWithMetadata {
1313 datum: UpstreamDatum::Homepage(homepage),
1314 certainty: Some(Certainty::Certain),
1315 origin: None,
1316 });
1317 }
1318
1319 if let Some(license) = self.info.license {
1320 metadata.insert(UpstreamDatumWithMetadata {
1321 datum: UpstreamDatum::License(license),
1322 certainty: Some(Certainty::Certain),
1323 origin: None,
1324 });
1325 }
1326
1327 metadata.insert(UpstreamDatumWithMetadata {
1328 datum: UpstreamDatum::Name(self.info.name),
1329 certainty: Some(Certainty::Certain),
1330 origin: None,
1331 });
1332
1333 if let Some(maintainer) = self.info.maintainer {
1334 metadata.insert(UpstreamDatumWithMetadata {
1335 datum: UpstreamDatum::Maintainer(Person {
1336 name: Some(maintainer),
1337 email: self.info.maintainer_email,
1338 url: None,
1339 }),
1340 certainty: Some(Certainty::Certain),
1341 origin: None,
1342 });
1343 }
1344
1345 metadata.insert(UpstreamDatumWithMetadata {
1346 datum: UpstreamDatum::Version(self.info.version),
1347 certainty: Some(Certainty::Certain),
1348 origin: None,
1349 });
1350
1351 if let Some(keywords) = self.info.keywords {
1352 metadata.insert(UpstreamDatumWithMetadata {
1353 datum: UpstreamDatum::Keywords(
1354 keywords.split(',').map(|s| s.trim().to_string()).collect(),
1355 ),
1356 certainty: Some(Certainty::Certain),
1357 origin: None,
1358 });
1359 }
1360
1361 if let Some(urls) = self.info.project_urls {
1362 metadata.0.extend(parse_python_project_urls(
1363 urls.into_iter(),
1364 &Origin::Other("pypi".to_string()),
1365 ));
1366 }
1367
1368 for url_data in self.urls {
1369 if url_data.packagetype == "sdist" {
1370 metadata.insert(UpstreamDatumWithMetadata {
1371 datum: UpstreamDatum::Download(url_data.url),
1372 certainty: Some(Certainty::Certain),
1373 origin: None,
1374 });
1375 }
1376 }
1377
1378 metadata.insert(UpstreamDatumWithMetadata {
1379 datum: UpstreamDatum::Summary(self.info.summary),
1380 certainty: Some(Certainty::Certain),
1381 origin: None,
1382 });
1383
1384 Ok(metadata)
1385 }
1386}
1387
1388pub async fn load_pypi_project(name: &str) -> Result<Option<PypiProject>, ProviderError> {
1390 let http_url = format!("https://pypi.org/pypi/{}/json", name)
1391 .parse()
1392 .unwrap();
1393 let data = crate::load_json_url(&http_url, None).await?;
1394 let pypi_data: PypiProject =
1395 serde_json::from_value(data).map_err(|e| crate::ProviderError::Other(e.to_string()))?;
1396 Ok(Some(pypi_data))
1397}
1398
1399pub async fn remote_pypi_metadata(name: &str) -> Result<UpstreamMetadata, ProviderError> {
1401 let pypi = load_pypi_project(name).await?;
1402
1403 match pypi {
1404 Some(pypi) => pypi.try_into(),
1405 None => Ok(UpstreamMetadata::default()),
1406 }
1407}
1408
1409#[cfg(test)]
1410mod pyproject_toml_tests {
1411 use super::*;
1412
1413 #[test]
1414 fn test_pep621_description_is_a_summary() {
1415 let dir = tempfile::tempdir().unwrap();
1416 let path = dir.path().join("pyproject.toml");
1417 std::fs::write(
1418 &path,
1419 r#"[project]
1420name = "markupsafe"
1421version = "3.0.3"
1422description = "Safely add untrusted strings to HTML/XML markup."
1423"#,
1424 )
1425 .unwrap();
1426
1427 let results = guess_from_pyproject_toml(&path, &GuesserSettings::default()).unwrap();
1428
1429 let summary = results.iter().find_map(|r| match &r.datum {
1430 UpstreamDatum::Summary(s) => Some(s.as_str()),
1431 _ => None,
1432 });
1433 assert_eq!(
1434 summary,
1435 Some("Safely add untrusted strings to HTML/XML markup.")
1436 );
1437 }
1438}
1439
1440#[cfg(test)]
1441mod pypi_tests {
1442 use super::*;
1443
1444 #[test]
1445 fn test_pypi_upstream_info() {
1446 let data = include_str!("../testdata/pypi.json");
1447
1448 let pypi_data: PypiProject = serde_json::from_str(data).unwrap();
1449
1450 assert_eq!(pypi_data.info.name, "merge3");
1451 }
1452}
1453
1454#[cfg(test)]
1455mod setup_cfg_tests {
1456 use super::*;
1457
1458 #[tokio::test]
1459 async fn test_setup_cfg_attr_version_skipped() {
1460 let dir = tempfile::tempdir().unwrap();
1461 let path = dir.path().join("setup.cfg");
1462 std::fs::write(
1463 &path,
1464 "[metadata]\nname = aiohttp-openmetrics\nversion = attr:aiohttp_openmetrics.__version__\n",
1465 )
1466 .unwrap();
1467 let settings = GuesserSettings::default();
1468 let results = guess_from_setup_cfg(&path, &settings).await.unwrap();
1469 assert!(
1470 results
1471 .iter()
1472 .all(|r| !matches!(&r.datum, UpstreamDatum::Version(_))),
1473 "attr: version directive should not be reported as a version"
1474 );
1475 assert!(results
1476 .iter()
1477 .any(|r| matches!(&r.datum, UpstreamDatum::Name(_))));
1478 }
1479
1480 #[tokio::test]
1481 async fn test_setup_cfg_static_version() {
1482 let dir = tempfile::tempdir().unwrap();
1483 let path = dir.path().join("setup.cfg");
1484 std::fs::write(&path, "[metadata]\nname = mypackage\nversion = 1.2.3\n").unwrap();
1485 let settings = GuesserSettings::default();
1486 let results = guess_from_setup_cfg(&path, &settings).await.unwrap();
1487 let version = results.iter().find_map(|r| match &r.datum {
1488 UpstreamDatum::Version(v) => Some(v.clone()),
1489 _ => None,
1490 });
1491 assert_eq!(version, Some("1.2.3".to_string()));
1492 }
1493}
1494
1495#[cfg(all(test, feature = "pyo3"))]
1496mod setup_py_tests {
1497 use super::*;
1498
1499 #[tokio::test]
1500 async fn test_setup_py_parsed_basic() {
1501 let dir = tempfile::tempdir().unwrap();
1502 let path = dir.path().join("setup.py");
1503 std::fs::write(
1504 &path,
1505 "from setuptools import setup\n\
1506 setup(\n\
1507 \x20 name='mypackage',\n\
1508 \x20 version='1.2.3',\n\
1509 \x20 description='A test package',\n\
1510 \x20 url='https://example.com/mypackage',\n\
1511 )\n",
1512 )
1513 .unwrap();
1514 let results = guess_from_setup_py(&path, false).await.unwrap();
1515 let name = results.iter().find_map(|r| match &r.datum {
1516 UpstreamDatum::Name(v) => Some(v.clone()),
1517 _ => None,
1518 });
1519 assert_eq!(name, Some("mypackage".to_string()));
1520 let version = results.iter().find_map(|r| match &r.datum {
1521 UpstreamDatum::Version(v) => Some(v.clone()),
1522 _ => None,
1523 });
1524 assert_eq!(version, Some("1.2.3".to_string()));
1525 }
1526}