vouch_py_lib/
lib.rs

1use anyhow::{format_err, Context, Result};
2use std::io::Read;
3use strum::IntoEnumIterator;
4
5mod pipfile;
6
7#[derive(Clone, Debug)]
8pub struct PyExtension {
9    name_: String,
10    registry_host_names_: Vec<String>,
11    root_url_: url::Url,
12    package_url_template_: String,
13    registry_human_url_template_: String,
14}
15
16impl vouch_lib::extension::FromLib for PyExtension {
17    fn new() -> Self {
18        Self {
19            name_: "py".to_string(),
20            registry_host_names_: vec!["pypi.org".to_owned()],
21            root_url_: url::Url::parse("https://pypi.org/pypi").unwrap(),
22            package_url_template_: "https://pypi.org/pypi/{{package_name}}/".to_string(),
23            registry_human_url_template_:
24                "https://pypi.org/pypi/{{package_name}}/{{package_version}}/".to_string(),
25        }
26    }
27}
28
29impl vouch_lib::extension::Extension for PyExtension {
30    fn name(&self) -> String {
31        self.name_.clone()
32    }
33
34    fn registries(&self) -> Vec<String> {
35        self.registry_host_names_.clone()
36    }
37
38    fn identify_local_dependencies(
39        &self,
40        working_directory: &std::path::PathBuf,
41    ) -> Result<Vec<vouch_lib::extension::DependenciesSpec>> {
42        // Identify all dependency definition files.
43        let dependency_files = match identify_dependency_files(&working_directory) {
44            Some(v) => v,
45            None => return Ok(Vec::new()),
46        };
47
48        // Read all dependencies definitions files.
49        let mut all_dependency_specs = Vec::new();
50        for dependency_file in dependency_files {
51            // TODO: Add support for parsing all definition file types.
52            let (dependencies, registry_host_name) = match dependency_file.r#type {
53                DependencyFileType::PipfileLock => (
54                    pipfile::get_dependencies(&dependency_file.path)?,
55                    pipfile::get_registry_host_name(),
56                ),
57            };
58            all_dependency_specs.push(vouch_lib::extension::DependenciesSpec {
59                path: dependency_file.path,
60                registry_host_name: registry_host_name,
61                dependencies: dependencies.into_iter().collect(),
62            });
63        }
64
65        Ok(all_dependency_specs)
66    }
67
68    fn registries_package_metadata(
69        &self,
70        package_name: &str,
71        package_version: &Option<&str>,
72    ) -> Result<Vec<vouch_lib::extension::RegistryPackageMetadata>> {
73        let package_version = match package_version {
74            Some(v) => Some(v.to_string()),
75            None => get_latest_version(&package_name)?,
76        }
77        .ok_or(format_err!("Failed to find package version."))?;
78
79        // Currently, only one registry is supported. Therefore simply select first.
80        let registry_host_name = self
81            .registries()
82            .first()
83            .ok_or(format_err!(
84                "Code error: vector of registry host names is empty."
85            ))?
86            .clone();
87
88        let entry_json = get_registry_entry_json(&package_name)?;
89        let artifact_url = get_archive_url(&entry_json, &package_version)?;
90        let human_url = get_registry_human_url(&self, &package_name, &package_version)?;
91
92        Ok(vec![vouch_lib::extension::RegistryPackageMetadata {
93            registry_host_name: registry_host_name,
94            human_url: human_url.to_string(),
95            artifact_url: artifact_url.to_string(),
96            is_primary: true,
97            package_version: package_version.to_string(),
98        }])
99    }
100}
101
102/// Given package name, return latest version.
103fn get_latest_version(package_name: &str) -> Result<Option<String>> {
104    let json = get_registry_entry_json(&package_name)?;
105    let releases = json["releases"]
106        .as_object()
107        .ok_or(format_err!("Failed to find releases JSON section."))?;
108    let mut versions: Vec<semver::Version> = releases
109        .keys()
110        .filter(|v| v.chars().all(|c| c.is_numeric() || c == '.'))
111        .map(|v| semver::Version::parse(v))
112        .filter(|v| v.is_ok())
113        .map(|v| v.unwrap())
114        .collect();
115    versions.sort();
116
117    let latest_version = versions.last().map(|v| v.to_string());
118    Ok(latest_version)
119}
120
121fn get_registry_human_url(
122    extension: &PyExtension,
123    package_name: &str,
124    package_version: &str,
125) -> Result<url::Url> {
126    // Example return value: https://pypi.org/pypi/numpy/1.18.5/
127    let handlebars_registry = handlebars::Handlebars::new();
128    let human_url = handlebars_registry.render_template(
129        &extension.registry_human_url_template_,
130        &maplit::btreemap! {
131            "package_name" => package_name,
132            "package_version" => package_version,
133        },
134    )?;
135    Ok(url::Url::parse(human_url.as_str())?)
136}
137
138fn get_registry_entry_json(package_name: &str) -> Result<serde_json::Value> {
139    let handlebars_registry = handlebars::Handlebars::new();
140    let url = handlebars_registry.render_template(
141        "https://pypi.org/pypi/{{package_name}}/json",
142        &maplit::btreemap! {
143            "package_name" => package_name,
144        },
145    )?;
146    let mut result = reqwest::blocking::get(&url.to_string())?;
147    let mut body = String::new();
148    result.read_to_string(&mut body)?;
149
150    Ok(serde_json::from_str(&body).context(format!("JSON was not well-formatted:\n{}", body))?)
151}
152
153fn get_archive_url(
154    registry_entry_json: &serde_json::Value,
155    package_version: &str,
156) -> Result<url::Url> {
157    let releases = registry_entry_json["releases"][package_version]
158        .as_array()
159        .ok_or(format_err!("Failed to parse releases array."))?;
160    for release in releases {
161        let python_version = release["python_version"]
162            .as_str()
163            .ok_or(format_err!("Failed to parse package version."))?;
164        if python_version == "source" {
165            return Ok(url::Url::parse(
166                release["url"]
167                    .as_str()
168                    .ok_or(format_err!("Failed to parse package archive URL."))?,
169            )?);
170        }
171    }
172    Err(format_err!("Failed to identify package archive URL."))
173}
174
175/// Package dependency file types.
176#[derive(Debug, Copy, Clone, strum_macros::EnumIter)]
177enum DependencyFileType {
178    PipfileLock,
179}
180
181impl DependencyFileType {
182    /// Return file name associated with dependency type.
183    pub fn file_name(&self) -> std::path::PathBuf {
184        match self {
185            Self::PipfileLock => std::path::PathBuf::from("Pipfile.lock"),
186        }
187    }
188}
189
190/// Package dependency file type and file path.
191#[derive(Debug, Clone)]
192struct DependencyFile {
193    r#type: DependencyFileType,
194    path: std::path::PathBuf,
195}
196
197/// Returns a vector of identified package dependency definition files.
198///
199/// Walks up the directory tree directory tree until the first positive result is found.
200fn identify_dependency_files(
201    working_directory: &std::path::PathBuf,
202) -> Option<Vec<DependencyFile>> {
203    assert!(working_directory.is_absolute());
204    let mut working_directory = working_directory.clone();
205
206    loop {
207        // If at least one target is found, assume package is present.
208        let mut found_dependency_file = false;
209
210        let mut dependency_files: Vec<DependencyFile> = Vec::new();
211        for dependency_file_type in DependencyFileType::iter() {
212            let target_absolute_path = working_directory.join(dependency_file_type.file_name());
213            if target_absolute_path.is_file() {
214                found_dependency_file = true;
215                dependency_files.push(DependencyFile {
216                    r#type: dependency_file_type,
217                    path: target_absolute_path,
218                })
219            }
220        }
221        if found_dependency_file {
222            return Some(dependency_files);
223        }
224
225        // No need to move further up the directory tree after this loop.
226        if working_directory == std::path::PathBuf::from("/") {
227            break;
228        }
229
230        // Move further up the directory tree.
231        working_directory.pop();
232    }
233    None
234}