use std::fs::File;
use std::io::{BufReader, BufWriter, Read, Write};
use std::path::Path;
use dspk_core::{DspError, DspResult};
mod npz;
pub use npz::{read_npz, read_npz_entries};
const MAGIC: &[u8; 6] = b"\x93NUMPY";
fn io(e: std::io::Error) -> DspError {
DspError::Io(e.to_string())
}
fn malformed(path: &Path, what: impl std::fmt::Display) -> DspError {
DspError::UnsupportedFormat(format!("{}: {what}", path.display()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NpyHeader {
pub descr: String,
pub fortran_order: bool,
pub shape: Vec<usize>,
}
impl NpyHeader {
pub fn len(&self) -> usize {
self.shape.iter().product()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct NpyArray<T> {
pub data: Vec<T>,
pub shape: Vec<usize>,
}
impl<T> NpyArray<T> {
pub fn dims<const N: usize>(&self, path: &Path) -> DspResult<[usize; N]> {
self.shape.as_slice().try_into().map_err(|_| malformed(path, format!("expected {N} dimensions, got shape {:?}", self.shape)))
}
}
pub trait NpyElement: Copy + Default {
fn from_u64(v: u64) -> Self;
fn from_i64(v: i64) -> Self;
fn from_f64(v: f64) -> Self;
}
macro_rules! npy_element {
($($t:ty),*) => {$(
impl NpyElement for $t {
#[inline]
fn from_u64(v: u64) -> Self { v as $t }
#[inline]
fn from_i64(v: i64) -> Self { v as $t }
#[inline]
fn from_f64(v: f64) -> Self { v as $t }
}
)*};
}
npy_element!(u8, u16, u32, u64, usize, i8, i16, i32, i64, f32, f64);
pub trait NpyWritable: Copy {
const DESCR: &'static str;
fn write_le(self, w: &mut impl Write) -> std::io::Result<()>;
}
macro_rules! npy_writable {
($($t:ty => $d:literal),*) => {$(
impl NpyWritable for $t {
const DESCR: &'static str = $d;
#[inline]
fn write_le(self, w: &mut impl Write) -> std::io::Result<()> { w.write_all(&self.to_le_bytes()) }
}
)*};
}
npy_writable!(u8 => "|u1", i32 => "<i4", u32 => "<u4", i64 => "<i8", u64 => "<u8", f32 => "<f4", f64 => "<f8");
pub fn read_header(r: &mut impl Read, path: &Path) -> DspResult<NpyHeader> {
let mut magic = [0u8; 6];
r.read_exact(&mut magic).map_err(io)?;
if &magic != MAGIC {
return Err(malformed(path, "not a .npy file"));
}
let mut ver = [0u8; 2];
r.read_exact(&mut ver).map_err(io)?;
let h_len = match ver[0] {
1 => {
let mut b = [0u8; 2];
r.read_exact(&mut b).map_err(io)?;
u16::from_le_bytes(b) as usize
}
2 | 3 => {
let mut b = [0u8; 4];
r.read_exact(&mut b).map_err(io)?;
u32::from_le_bytes(b) as usize
}
v => return Err(malformed(path, format!("unsupported .npy version {v}"))),
};
let mut buf = vec![0u8; h_len];
r.read_exact(&mut buf).map_err(io)?;
parse_header(&String::from_utf8_lossy(&buf)).ok_or_else(|| malformed(path, "malformed .npy header"))
}
fn value_after<'a>(header: &'a str, key: &str) -> Option<&'a str> {
let at = header.find(&format!("'{key}'")).or_else(|| header.find(&format!("\"{key}\"")))?;
let rest = &header[at + key.len() + 2..];
Some(rest[rest.find(':')? + 1..].trim_start())
}
fn parse_header(header: &str) -> Option<NpyHeader> {
let d = value_after(header, "descr")?;
let quote = d.chars().next().filter(|q| *q == '\'' || *q == '"')?;
let descr = d[1..].split(quote).next()?.to_string();
let fortran_order = value_after(header, "fortran_order")?.starts_with("True");
let s = value_after(header, "shape")?;
let inner = s.strip_prefix('(')?.split(')').next()?;
let shape = inner.split(',').map(str::trim).filter(|t| !t.is_empty()).map(|t| t.parse().ok()).collect::<Option<Vec<usize>>>()?;
Some(NpyHeader { descr, fortran_order, shape })
}
fn convert<T: NpyElement>(descr: &str, bytes: &[u8], path: &Path) -> DspResult<Vec<T>> {
let big = descr.starts_with('>');
let code = descr.trim_start_matches(['<', '>', '|', '=']);
macro_rules! each {
($n:literal, $t:ty, $to:ident, $as:ty) => {
bytes.chunks_exact($n).map(|c| {
let a: [u8; $n] = c.try_into().unwrap();
T::$to((if big { <$t>::from_be_bytes(a) } else { <$t>::from_le_bytes(a) }) as $as)
}).collect()
};
}
Ok(match code {
"b1" | "u1" => bytes.iter().map(|&b| T::from_u64(b as u64)).collect(),
"i1" => bytes.iter().map(|&b| T::from_i64(b as i8 as i64)).collect(),
"u2" => each!(2, u16, from_u64, u64),
"i2" => each!(2, i16, from_i64, i64),
"u4" => each!(4, u32, from_u64, u64),
"i4" => each!(4, i32, from_i64, i64),
"u8" => each!(8, u64, from_u64, u64),
"i8" => each!(8, i64, from_i64, i64),
"f4" => each!(4, f32, from_f64, f64),
"f8" => each!(8, f64, from_f64, f64),
other => return Err(malformed(path, format!("unsupported dtype '{other}'"))),
})
}
fn dtype_size(descr: &str) -> Option<usize> {
descr.trim_start_matches(['<', '>', '|', '=']).get(1..)?.parse().ok()
}
fn to_c_order<T: Copy + Default>(data: Vec<T>, shape: &[usize]) -> Vec<T> {
if shape.len() < 2 {
return data;
}
let mut out = vec![T::default(); data.len()];
let mut index = vec![0usize; shape.len()];
for v in data {
let c = index.iter().zip(shape).fold(0usize, |acc, (&i, &n)| acc * n + i);
out[c] = v;
for (i, &n) in index.iter_mut().zip(shape) {
*i += 1;
if *i < n {
break;
}
*i = 0;
}
}
out
}
pub fn read_npy<T: NpyElement>(path: &Path) -> DspResult<NpyArray<T>> {
let file = File::open(path).map_err(|e| DspError::Io(format!("{}: {e}", path.display())))?;
read_npy_from(&mut BufReader::new(file), path)
}
pub fn read_npy_bytes<T: NpyElement>(bytes: &[u8], label: &Path) -> DspResult<NpyArray<T>> {
read_npy_from(&mut std::io::Cursor::new(bytes), label)
}
fn read_npy_from<T: NpyElement>(r: &mut impl Read, path: &Path) -> DspResult<NpyArray<T>> {
let h = read_header(r, path)?;
let size = dtype_size(&h.descr).ok_or_else(|| malformed(path, format!("unsupported dtype '{}'", h.descr)))?;
let mut bytes = vec![0u8; h.len() * size];
r.read_exact(&mut bytes).map_err(|e| malformed(path, format!("truncated data: {e}")))?;
let data = convert::<T>(&h.descr, &bytes, path)?;
let data = if h.fortran_order { to_c_order(data, &h.shape) } else { data };
Ok(NpyArray { data, shape: h.shape })
}
pub fn write_npy<T: NpyWritable>(path: &Path, data: &[T], shape: &[usize]) -> DspResult<()> {
let expected: usize = shape.iter().product();
if expected != data.len() {
return Err(DspError::InvalidConfig(format!("{}: {} values do not fill shape {shape:?}", path.display(), data.len())));
}
let dims = match shape {
[n] => format!("({n},)"),
_ => format!("({})", shape.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(", ")),
};
let mut dict = format!("{{'descr': '{}', 'fortran_order': False, 'shape': {dims}, }}", T::DESCR);
let unpadded = 10 + dict.len() + 1;
dict.push_str(&" ".repeat(unpadded.div_ceil(64) * 64 - unpadded));
dict.push('\n');
let mut w = BufWriter::new(File::create(path).map_err(|e| DspError::Io(format!("{}: {e}", path.display())))?);
w.write_all(MAGIC).map_err(io)?;
w.write_all(&[1, 0]).map_err(io)?;
w.write_all(&(dict.len() as u16).to_le_bytes()).map_err(io)?;
w.write_all(dict.as_bytes()).map_err(io)?;
for &v in data {
v.write_le(&mut w).map_err(io)?;
}
w.flush().map_err(io)
}
pub fn read_npy_u64_1d(path: &Path) -> DspResult<Vec<u64>> {
Ok(read_npy::<u64>(path)?.data)
}
pub fn read_npy_i32_1d(path: &Path) -> DspResult<Vec<i32>> {
Ok(read_npy::<i32>(path)?.data)
}
pub fn read_npy_f32_1d(path: &Path) -> DspResult<Vec<f32>> {
Ok(read_npy::<f32>(path)?.data)
}
pub fn read_npy_f32_2d(path: &Path) -> DspResult<(Vec<f32>, [usize; 2])> {
let a = read_npy::<f32>(path)?;
let dims = a.dims::<2>(path)?;
Ok((a.data, dims))
}
pub fn read_npy_f32_3d(path: &Path) -> DspResult<(Vec<f32>, [usize; 3])> {
let a = read_npy::<f32>(path)?;
let dims = a.dims::<3>(path)?;
Ok((a.data, dims))
}
pub fn write_npy_u64_1d(path: &Path, slice: &[u64]) -> DspResult<()> {
write_npy(path, slice, &[slice.len()])
}
pub fn write_npy_i32_1d(path: &Path, slice: &[i32]) -> DspResult<()> {
write_npy(path, slice, &[slice.len()])
}
pub fn write_npy_f32_1d(path: &Path, slice: &[f32]) -> DspResult<()> {
write_npy(path, slice, &[slice.len()])
}
pub fn write_npy_f32_2d(path: &Path, slice: &[f32], shape: [usize; 2]) -> DspResult<()> {
write_npy(path, slice, &shape)
}
pub fn write_npy_f32_3d(path: &Path, slice: &[f32], shape: [usize; 3]) -> DspResult<()> {
write_npy(path, slice, &shape)
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("dsp_npy_{name}_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn raw(path: &Path, descr: &str, fortran: bool, shape: &str, data: &[u8], version: u8) {
let mut dict = format!("{{'descr': '{descr}', 'fortran_order': {}, 'shape': {shape}, }}", if fortran { "True" } else { "False" });
let prefix = if version == 1 { 10 } else { 12 };
let unpadded = prefix + dict.len() + 1;
dict.push_str(&" ".repeat(unpadded.div_ceil(64) * 64 - unpadded));
dict.push('\n');
let mut f = File::create(path).unwrap();
f.write_all(MAGIC).unwrap();
f.write_all(&[version, 0]).unwrap();
if version == 1 {
f.write_all(&(dict.len() as u16).to_le_bytes()).unwrap();
} else {
f.write_all(&(dict.len() as u32).to_le_bytes()).unwrap();
}
f.write_all(dict.as_bytes()).unwrap();
f.write_all(data).unwrap();
}
#[test]
fn test_npy_roundtrip_1d_and_3d() {
let dir = scratch("roundtrip");
let u64_path = dir.join("times.npy");
let data_u64 = vec![100u64, 250, 4000, 99999];
write_npy_u64_1d(&u64_path, &data_u64).unwrap();
assert_eq!(read_npy_u64_1d(&u64_path).unwrap(), data_u64);
let f32_path = dir.join("templates.npy");
let data_3d = vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
write_npy_f32_3d(&f32_path, &data_3d, [2, 2, 2]).unwrap();
assert_eq!(read_npy_f32_3d(&f32_path).unwrap(), (data_3d, [2, 2, 2]));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_reads_other_dtypes_exactly() {
let dir = scratch("dtypes");
let p = dir.join("i8.npy");
let v: Vec<i64> = vec![16_777_217, 3, 9_007_199_254_740_993];
write_npy(&p, &v, &[3]).unwrap();
assert_eq!(read_npy::<u64>(&p).unwrap().data, vec![16_777_217, 3, 9_007_199_254_740_993]);
let p = dir.join("f8.npy");
write_npy(&p, &[1.5f64, -2.25, 3.0, 4.0], &[2, 2]).unwrap();
assert_eq!(read_npy_f32_2d(&p).unwrap(), (vec![1.5, -2.25, 3.0, 4.0], [2, 2]));
let p = dir.join("be.npy");
raw(&p, ">i2", false, "(2,)", &[0xff, 0xfe, 0x00, 0x05], 1);
assert_eq!(read_npy::<i32>(&p).unwrap().data, vec![-2, 5]);
let p = dir.join("u1.npy");
raw(&p, "|b1", false, "(3,)", &[1, 0, 1], 1);
assert_eq!(read_npy::<u8>(&p).unwrap().data, vec![1, 0, 1]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_fortran_order_and_version_2() {
let dir = scratch("fortran");
let p = dir.join("f.npy");
let cols: Vec<u8> = [0i32, 3, 1, 4, 2, 5].iter().flat_map(|v| v.to_le_bytes()).collect();
raw(&p, "<i4", true, "(2, 3)", &cols, 2);
let a = read_npy::<i32>(&p).unwrap();
assert_eq!((a.data, a.shape), (vec![0, 1, 2, 3, 4, 5], vec![2, 3]));
let mut f = Vec::new();
for k in 0..2i32 {
for j in 0..2i32 {
for i in 0..2i32 {
f.extend((100 * i + 10 * j + k).to_le_bytes());
}
}
}
raw(&p, "<i4", true, "(2, 2, 2)", &f, 1);
assert_eq!(read_npy::<i32>(&p).unwrap().data, vec![0, 1, 10, 11, 100, 101, 110, 111]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_errors_name_the_problem() {
let dir = scratch("errors");
let p = dir.join("bad.npy");
std::fs::write(&p, b"not numpy").unwrap();
assert!(read_npy::<f32>(&p).unwrap_err().to_string().contains("not a .npy"));
write_npy(&p, &[1.0f32, 2.0], &[2]).unwrap();
assert!(read_npy_f32_2d(&p).unwrap_err().to_string().contains("expected 2 dimensions"));
assert!(write_npy(&p, &[1.0f32], &[2]).is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
}