Skip to main content

upstream_ontologist/providers/
python.rs

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")]
14/// Extracts upstream metadata from Python PKG-INFO file
15pub 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/// Extracts upstream metadata from Python pyproject.toml file
123#[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        // PEP 621 defines description as a short summary, not the long form.
180        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
450/// Parses a Python URL to extract upstream metadata
451pub 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")]
476/// Extracts upstream metadata from Python setup.cfg file
477pub 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                // Skip setuptools dynamic version directives like
507                // "attr:package.__version__" or "file:VERSION"
508                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                // Ignore these, they are handled elsewhere
623            }
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    // Ensure only one thread can run this function at a time
638    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    // Import setuptools, just in case it replaces distutils
642    //
643    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")]
786/// Extracts upstream metadata from Python setup.py file
787pub 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        // Based on pypi.py in https://github.com/nexB/scancode-toolkit/blob/develop/src/packagedcode/pypi.py
819        //
820        // Copyright (c) nexB Inc. and others. All rights reserved.
821        // ScanCode is a trademark of nexB Inc.
822        // SPDX-License-Identifier: Apache-2.0
823
824        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            // We only care about function calls or assignments to functions named
835            // `setup` or `main`
836            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                    // we also look for main as sometimes this is used instead of
846                    // setup()
847                    statement.getattr("value")?.getattr("func")?.getattr("id")?.extract::<String>()? == "main")
848            {
849                let value = statement.getattr("value")?;
850
851                // Process the arguments to the setup function
852                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        // End code from https://github.com/nexB/scancode-toolkit/blob/develop/src/packagedcode/pypi.py
862
863        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            // We collect the elements of a list if the element
879            // and tag function calls
880            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        // TODO: what if kw.value is an expression like a call to
925        // version=get_version or version__version__
926
927        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                // Handled above
1066                "author_email" | "maintainer_email" => {},
1067                // Irrelevant
1068                "rust_extensions" | "data_files" | "packages" | "package_dir" | "entry_points" | "package_data" => {},
1069                // Irrelevant: dependencies
1070                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/// PyPI project information from the PyPI API
1136#[derive(Deserialize)]
1137pub struct PypiProjectInfo {
1138    /// Package author name
1139    pub author: Option<String>,
1140    /// Package author email
1141    pub author_email: Option<String>,
1142    /// Bug tracker URL
1143    pub bugtrack_url: Option<String>,
1144    /// PyPI trove classifiers
1145    pub classifiers: Vec<String>,
1146    /// Package description
1147    pub description: String,
1148    /// Content type of the description (e.g., text/markdown)
1149    pub description_content_type: Option<String>,
1150    /// Documentation URL
1151    pub docs_url: Option<String>,
1152    /// Package download URL
1153    pub download_url: Option<String>,
1154    /// Download statistics
1155    pub downloads: HashMap<String, isize>,
1156    /// Whether the project uses dynamic metadata
1157    pub dynamic: Option<bool>,
1158    /// Project homepage URL
1159    pub home_page: Option<String>,
1160    /// Package keywords
1161    pub keywords: Option<String>,
1162    /// Package license
1163    pub license: Option<String>,
1164    /// Package maintainer name
1165    pub maintainer: Option<String>,
1166    /// Package maintainer email
1167    pub maintainer_email: Option<String>,
1168    /// Package name
1169    pub name: String,
1170    /// PyPI package URL
1171    pub package_url: String,
1172    /// Target platform
1173    pub platform: Option<String>,
1174    /// PyPI project URL
1175    pub project_url: String,
1176    /// Additional project URLs
1177    pub project_urls: Option<HashMap<String, String>>,
1178    /// Whether the package provides extras
1179    pub provides_extra: Option<bool>,
1180    /// Current release URL
1181    pub release_url: String,
1182    /// Distribution requirements
1183    pub requires_dist: Option<Vec<String>>,
1184    /// Required Python version
1185    pub requires_python: Option<String>,
1186    /// Package summary
1187    pub summary: String,
1188    /// Package version
1189    pub version: String,
1190    /// Whether the release is yanked
1191    pub yanked: Option<bool>,
1192    /// Reason for yanking the release
1193    pub yanked_reason: Option<String>,
1194}
1195
1196/// Cryptographic digests for a PyPI release
1197#[derive(Deserialize)]
1198pub struct Digests {
1199    /// MD5 hash digest
1200    pub md5: String,
1201    /// SHA256 hash digest
1202    pub sha256: String,
1203    /// BLAKE2b-256 hash digest
1204    pub blake2b_256: String,
1205}
1206
1207/// Information about a specific PyPI release
1208#[derive(Deserialize)]
1209pub struct PypiRelease {
1210    /// Release comment text
1211    pub comment_text: String,
1212    /// Cryptographic digests for this release
1213    pub digests: Digests,
1214    /// Number of downloads
1215    pub downloads: isize,
1216    /// Release filename
1217    pub filename: String,
1218    /// Whether the release has a signature
1219    pub has_sig: bool,
1220    /// MD5 digest of the release file
1221    pub md5_digest: String,
1222    /// Package type (e.g., sdist, bdist_wheel)
1223    pub packagetype: String,
1224    /// Target Python version
1225    pub python_version: String,
1226    /// Required Python version for this release
1227    pub requires_python: Option<String>,
1228    /// File size in bytes
1229    pub size: isize,
1230    /// Upload timestamp
1231    pub upload_time: String,
1232    /// Upload timestamp in ISO 8601 format
1233    pub upload_time_iso_8601: String,
1234    /// Download URL for this release
1235    pub url: String,
1236    /// Whether this release is yanked
1237    pub yanked: bool,
1238    /// Reason for yanking this release
1239    pub yanked_reason: Option<String>,
1240}
1241
1242/// PyPI URL information for a release artifact
1243#[derive(Deserialize)]
1244pub struct PypiUrl {
1245    /// Comment text for this URL
1246    pub comment_text: String,
1247    /// Cryptographic digests for this URL
1248    pub digests: Digests,
1249    /// Filename for this URL
1250    pub filename: String,
1251    /// Whether this URL has a signature
1252    pub has_sig: bool,
1253    /// Package type for this URL
1254    pub packagetype: String,
1255    /// Target Python version for this URL
1256    pub python_version: String,
1257    /// Required Python version for this URL
1258    pub requires_python: Option<String>,
1259    /// File size in bytes for this URL
1260    pub size: isize,
1261    /// Upload timestamp for this URL
1262    pub upload_time: String,
1263    /// Upload timestamp in ISO 8601 format for this URL
1264    pub upload_time_iso_8601: String,
1265    /// The actual download URL
1266    pub url: String,
1267    /// Whether this URL release is yanked
1268    pub yanked: bool,
1269    /// Reason for yanking this URL release
1270    pub yanked_reason: Option<String>,
1271}
1272
1273/// Complete PyPI project metadata
1274#[derive(Deserialize)]
1275pub struct PypiProject {
1276    /// Project information
1277    pub info: PypiProjectInfo,
1278    /// Last serial number for the project
1279    pub last_serial: isize,
1280    /// All releases for this project
1281    pub releases: HashMap<String, Vec<PypiRelease>>,
1282    /// URLs for the current release
1283    pub urls: Vec<PypiUrl>,
1284    /// Known security vulnerabilities
1285    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
1388/// Loads PyPI project data from the PyPI JSON API
1389pub 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
1399/// Retrieves upstream metadata for a Python package from PyPI
1400pub 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}