Skip to main content

provenant/parsers/
pip_inspect_deplock.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Parser for pip inspect deplock files.
7//!
8//! Extracts package metadata from `pip-inspect.deplock` files which contain
9//! installed Python package information generated by dependency-inspector.
10//!
11//! # Supported Formats
12//! - `pip-inspect.deplock` - pip inspect output in deplock format
13//!
14//! # Key Features
15//! - Installed package identification
16//! - Main package extraction
17//! - License and description metadata
18//!
19//! # Implementation Notes
20//! - Format: JSON with installed packages list
21//! - Generated by dependency-inspector tool
22//! - Spec: https://pip.pypa.io/en/stable/cli/pip_inspect/
23
24use crate::models::{PackageData, PackageType};
25use std::path::Path;
26
27#[cfg(test)]
28use crate::models::DatasourceId;
29#[cfg(test)]
30use crate::parser_warn as warn;
31#[cfg(test)]
32use serde::{Deserialize, Serialize};
33#[cfg(test)]
34use serde_json::Value;
35
36use super::PackageParser;
37#[cfg(test)]
38use super::license_normalization::normalize_spdx_declared_license;
39use super::metadata::ParserMetadata;
40use super::python::PythonParser;
41#[cfg(test)]
42use super::python::extract_requires_dist_dependencies;
43#[cfg(test)]
44use crate::parsers::utils::{CappedIterExt, truncate_field};
45
46const PACKAGE_TYPE: PackageType = PackageType::Pypi;
47
48#[cfg(test)]
49fn default_package_data() -> PackageData {
50    PackageData {
51        package_type: Some(PACKAGE_TYPE),
52        primary_language: Some("Python".to_string()),
53        datasource_id: Some(DatasourceId::PypiInspectDeplock),
54        ..Default::default()
55    }
56}
57
58/// Parser for pip inspect deplock files
59pub struct PipInspectDeplockParser;
60
61#[cfg(test)]
62#[derive(Debug, Deserialize, Serialize)]
63struct PipInspectDeplock {
64    installed: Option<Vec<InstalledPackage>>,
65    pip_version: Option<String>,
66    version: Option<String>,
67}
68
69#[cfg(test)]
70#[derive(Debug, Deserialize, Serialize)]
71struct InstalledPackage {
72    metadata: Option<PackageMetadata>,
73    requested: Option<bool>,
74    direct_url: Option<Value>,
75}
76
77#[cfg(test)]
78#[derive(Debug, Deserialize, Serialize)]
79struct PackageMetadata {
80    name: Option<String>,
81    version: Option<String>,
82    license: Option<String>,
83    description: Option<String>,
84    keywords: Option<String>,
85    requires_dist: Option<Vec<String>>,
86}
87
88impl PackageParser for PipInspectDeplockParser {
89    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
90
91    fn metadata() -> Vec<ParserMetadata> {
92        vec![ParserMetadata {
93            description: "pip inspect deplock file",
94            file_patterns: &["*pip-inspect.deplock"],
95            package_type: "pypi",
96            primary_language: "Python",
97            documentation_url: Some("https://pip.pypa.io/en/stable/cli/pip_inspect/"),
98        }]
99    }
100
101    fn is_match(path: &Path) -> bool {
102        path.to_str()
103            .is_some_and(|p| p.ends_with("/pip-inspect.deplock"))
104    }
105
106    fn extract_packages(path: &Path) -> Vec<PackageData> {
107        vec![PythonParser::extract_first_package(path)]
108    }
109}
110
111#[cfg(test)]
112pub(crate) fn parse_pip_inspect_deplock(content: &str) -> PackageData {
113    let data: PipInspectDeplock = match serde_json::from_str(content) {
114        Ok(d) => d,
115        Err(e) => {
116            warn!("Failed to parse pip-inspect.deplock: {}", e);
117            return default_package_data();
118        }
119    };
120
121    let Some(installed_packages) = data.installed else {
122        return default_package_data();
123    };
124
125    // Find the main package (has direct_url and is_requested)
126    let main_package = installed_packages
127        .iter()
128        .capped("pip inspect installed packages")
129        .find(|p| p.requested.unwrap_or(false) && p.direct_url.is_some());
130
131    let metadata = if let Some(pkg) = main_package {
132        pkg.metadata.as_ref()
133    } else {
134        // If no main package found, try to find any requested package
135        installed_packages
136            .iter()
137            .capped("pip inspect installed packages")
138            .find(|p| p.requested.unwrap_or(false))
139            .and_then(|p| p.metadata.as_ref())
140    };
141
142    let Some(metadata) = metadata else {
143        return default_package_data();
144    };
145
146    // Build extra_data with pip version info
147    let mut extra_data = std::collections::HashMap::new();
148    if let Some(ref pip_version) = data.pip_version {
149        extra_data.insert(
150            "pip_version".to_string(),
151            Value::String(pip_version.clone()),
152        );
153    }
154    if let Some(ref inspect_version) = data.version {
155        extra_data.insert(
156            "inspect_version".to_string(),
157            Value::String(inspect_version.clone()),
158        );
159    }
160
161    let extra_data_opt = if extra_data.is_empty() {
162        None
163    } else {
164        Some(extra_data)
165    };
166
167    let keywords = metadata
168        .keywords
169        .as_ref()
170        .map(|k| vec![truncate_field(k.clone())])
171        .unwrap_or_default();
172    let (declared_license_expression, declared_license_expression_spdx, license_detections) =
173        normalize_spdx_declared_license(metadata.license.as_deref());
174    let dependencies = metadata
175        .requires_dist
176        .as_ref()
177        .map(|requires_dist| extract_requires_dist_dependencies(requires_dist))
178        .unwrap_or_default();
179
180    PackageData {
181        package_type: Some(PACKAGE_TYPE),
182        primary_language: Some("Python".to_string()),
183        name: metadata.name.as_ref().map(|v| truncate_field(v.clone())),
184        version: metadata.version.as_ref().map(|v| truncate_field(v.clone())),
185        declared_license_expression,
186        declared_license_expression_spdx,
187        license_detections,
188        extracted_license_statement: metadata.license.as_ref().map(|v| truncate_field(v.clone())),
189        description: metadata
190            .description
191            .as_ref()
192            .map(|v| truncate_field(v.clone())),
193        keywords,
194        is_virtual: true,
195        extra_data: extra_data_opt,
196        dependencies,
197        datasource_id: Some(DatasourceId::PypiInspectDeplock),
198        ..Default::default()
199    }
200}