use std::collections::HashMap;
use znippy_zoomies::gatling_forkjoin::gatling_map_balanced;
use znippy_common::arrow::datatypes::{DataType, Field};
use znippy_common::plugin::{ArchiveTypePlugin, BatchItem, ExtensionRow, ExtensionValue, HandlerMeta};
use crate::wheel::parse_wheel_filename;
pub struct NativePythonPlugin;
impl ArchiveTypePlugin for NativePythonPlugin {
fn name(&self) -> &str {
"python"
}
fn type_id(&self) -> i8 {
2
}
fn meta(&self) -> HandlerMeta {
HandlerMeta {
name: "python".into(),
aliases: vec!["pip".into(), "pypi".into()],
type_id: 2,
ecosystem: "Python / PyPI".into(),
extensions: vec![".whl".into(), ".tar.gz".into(), ".zip".into()],
description: "Python wheels & sdists — name/version/tags from filename, deps from METADATA".into(),
commands: vec![],
}
}
fn matches_path(&self, path: &str) -> bool {
path.ends_with(".whl")
|| path.ends_with(".tar.gz")
|| path.ends_with(".zip")
}
fn schema_fields(&self) -> Vec<Field> {
vec![
Field::new("name", DataType::Utf8, true),
Field::new("version", DataType::Utf8, true),
Field::new("python_tag", DataType::Utf8, true),
Field::new("abi_tag", DataType::Utf8, true),
Field::new("platform_tag", DataType::Utf8, true),
Field::new("build_tag", DataType::Utf8, true),
]
}
fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
let filename = path.rsplit('/').next().unwrap_or(path);
if filename.ends_with(".whl") {
let info = parse_wheel_filename(filename)?;
let metadata_content = extract_metadata_via_lzip(data);
let mut fields = HashMap::new();
fields.insert("name".into(), ExtensionValue::Str(info.name));
fields.insert("version".into(), ExtensionValue::Str(info.version));
fields.insert("python_tag".into(), ExtensionValue::Str(info.python_tag));
fields.insert("abi_tag".into(), ExtensionValue::Str(info.abi_tag));
fields.insert("platform_tag".into(), ExtensionValue::Str(info.platform_tag));
if let Some(build) = info.build_tag {
fields.insert("build_tag".into(), ExtensionValue::OptStr(Some(build)));
}
if let Some(meta) = metadata_content {
let deps = parse_requires_dist(&meta);
if !deps.is_empty() {
fields.insert("requires_dist".into(), ExtensionValue::StrList(deps));
}
}
Some(ExtensionRow { fields })
} else {
let stem = if filename.ends_with(".tar.gz") {
filename.strip_suffix(".tar.gz")?
} else {
filename.strip_suffix(".zip")?
};
let (name, version) = stem.rsplit_once('-')?;
let mut fields = HashMap::new();
fields.insert("name".into(), ExtensionValue::Str(name.to_string()));
fields.insert("version".into(), ExtensionValue::Str(version.to_string()));
fields.insert("python_tag".into(), ExtensionValue::Str("source".to_string()));
fields.insert("abi_tag".into(), ExtensionValue::Str("none".to_string()));
fields.insert("platform_tag".into(), ExtensionValue::Str("any".to_string()));
Some(ExtensionRow { fields })
}
}
fn supports_batch(&self) -> bool {
true
}
fn batch_threshold(&self) -> usize {
200 * 1024 * 1024
}
fn extract_batch(&self, items: &[BatchItem<'_>]) -> Vec<Option<ExtensionRow>> {
let rows = gatling_map_balanced(
items,
0,
1,
|item| item.data.len() as u64,
|_, item| self.extract_metadata(item.path, item.data),
);
#[cfg(feature = "testmatrix")]
functional_status(
"znippy-plugin-python/native",
"gatling_map_balanced",
rows.len() == items.len(),
&format!("items={} rows={} order-preserving fan-out", items.len(), rows.len()),
);
rows
}
}
#[cfg(feature = "testmatrix")]
#[inline]
fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
nornir_testmatrix::functional_status(component, check, ok, detail);
}
fn extract_metadata_via_lzip(data: &[u8]) -> Option<String> {
let entries = lzip_parallel::decompress_zip_filter(data, "METADATA").ok()?;
let entry = entries.iter()
.find(|e| e.name.contains(".dist-info/") && e.name.ends_with("/METADATA"))?;
String::from_utf8(entry.data.clone()).ok()
}
fn parse_requires_dist(metadata: &str) -> Vec<String> {
metadata.lines()
.filter(|line| line.starts_with("Requires-Dist:"))
.map(|line| line.trim_start_matches("Requires-Dist:").trim().to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_matches_path() {
let plugin = NativePythonPlugin;
assert!(plugin.matches_path("packages/numpy/numpy-1.26.0-cp311-cp311-linux_x86_64.whl"));
assert!(plugin.matches_path("packages/requests/requests-2.31.0.tar.gz"));
assert!(!plugin.matches_path("packages/something.jar"));
}
#[test]
fn test_extract_from_filename() {
let plugin = NativePythonPlugin;
let row = plugin.extract_metadata(
"packages/numpy/numpy-1.26.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
&[], ).unwrap();
assert_eq!(row.fields["name"], ExtensionValue::Str("numpy".into()));
assert_eq!(row.fields["version"], ExtensionValue::Str("1.26.0".into()));
assert_eq!(row.fields["python_tag"], ExtensionValue::Str("cp311".into()));
}
#[test]
fn test_sdist_extraction() {
let plugin = NativePythonPlugin;
let row = plugin.extract_metadata("packages/requests/requests-2.31.0.tar.gz", &[]).unwrap();
assert_eq!(row.fields["name"], ExtensionValue::Str("requests".into()));
assert_eq!(row.fields["version"], ExtensionValue::Str("2.31.0".into()));
assert_eq!(row.fields["python_tag"], ExtensionValue::Str("source".into()));
}
}