Skip to main content

provenant/parsers/
pip_inspect_deplock.rs

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