1use std::path::Path;
8
9use nuvec_core::{Array, DType, Error, MappedFile, Result, transpose};
10
11use crate::safetensors::array_bytes;
12
13fn invalid(msg: impl Into<String>) -> Error {
14 Error::InvalidArgument(format!("npy: {}", msg.into()))
15}
16
17fn descr_of(dtype: DType) -> Option<&'static str> {
19 Some(match dtype {
20 DType::Bool => "|b1",
21 DType::U8 => "|u1",
22 DType::U16 => "<u2",
23 DType::U32 => "<u4",
24 DType::U64 => "<u8",
25 DType::I8 => "|i1",
26 DType::I16 => "<i2",
27 DType::I32 => "<i4",
28 DType::I64 => "<i8",
29 DType::F16 => "<f2",
30 DType::F32 => "<f4",
31 DType::F64 => "<f8",
32 DType::Complex64 => "<c8",
33 _ => return None,
34 })
35}
36
37fn dtype_of(descr: &str) -> Option<DType> {
38 let canonical = match descr.as_bytes() {
40 [b'=' | b'<' | b'|', rest @ ..] => format!("<{}", std::str::from_utf8(rest).ok()?),
41 _ => return None,
42 };
43 [
44 DType::Bool,
45 DType::U8,
46 DType::U16,
47 DType::U32,
48 DType::U64,
49 DType::I8,
50 DType::I16,
51 DType::I32,
52 DType::I64,
53 DType::F16,
54 DType::F32,
55 DType::F64,
56 DType::Complex64,
57 ]
58 .into_iter()
59 .find(|&d| descr_of(d).is_some_and(|s| s[1..] == canonical[1..]))
60}
61
62fn field<'a>(header: &'a str, key: &str) -> Result<&'a str> {
64 let pat = format!("'{key}':");
65 let at = header
66 .find(&pat)
67 .ok_or_else(|| invalid(format!("header has no `{key}`")))?;
68 Ok(header[at + pat.len()..].trim_start())
69}
70
71pub fn load_npy(path: impl AsRef<Path>) -> Result<Array> {
73 let file = MappedFile::open(path)?;
74 let bytes = file.bytes();
75 if bytes.get(..6) != Some(b"\x93NUMPY") || bytes.len() < 10 {
76 return Err(invalid("not an npy file"));
77 }
78 let (len, start) = match bytes[6] {
79 1 => (usize::from(u16::from_le_bytes([bytes[8], bytes[9]])), 10),
80 2 | 3 => (
81 bytes
82 .get(8..12)
83 .map(|b| u32::from_le_bytes(b.try_into().expect("4 bytes")) as usize)
84 .ok_or_else(|| invalid("truncated header"))?,
85 12,
86 ),
87 v => return Err(invalid(format!("unsupported version {v}"))),
88 };
89 let header = bytes
90 .get(start..start + len)
91 .and_then(|h| std::str::from_utf8(h).ok())
92 .ok_or_else(|| invalid("truncated or non-text header"))?;
93 let descr = field(header, "descr")?;
94 let descr = descr
95 .strip_prefix('\'')
96 .and_then(|d| d.split('\'').next())
97 .ok_or_else(|| invalid("bad descr"))?;
98 let dtype = dtype_of(descr).ok_or_else(|| invalid(format!("unsupported type {descr}")))?;
99 let fortran = field(header, "fortran_order")?.starts_with("True");
100 let shape_text = field(header, "shape")?;
101 let close = shape_text.find(')').ok_or_else(|| invalid("bad shape"))?;
102 let shape = shape_text[1..close]
103 .split(',')
104 .map(str::trim)
105 .filter(|s| !s.is_empty())
106 .map(|s| s.parse::<usize>().map_err(|_| invalid("bad shape")))
107 .collect::<Result<Vec<_>>>()?;
108 let data = start + len;
109 if fortran && shape.len() > 1 {
110 let reversed: Vec<usize> = shape.iter().rev().copied().collect();
111 let a = file.array(data, dtype, &reversed)?;
112 let axes: Vec<usize> = (0..shape.len()).rev().collect();
113 transpose(&a, &axes)
114 } else {
115 file.array(data, dtype, &shape)
116 }
117}
118
119pub fn save_npy(path: impl AsRef<Path>, a: &Array) -> Result<()> {
122 let descr =
123 descr_of(a.dtype()).ok_or_else(|| invalid(format!("{} has no NumPy type", a.dtype())))?;
124 let shape = match a.shape() {
125 [n] => format!("({n},)"),
126 s => format!(
127 "({})",
128 s.iter()
129 .map(usize::to_string)
130 .collect::<Vec<_>>()
131 .join(", ")
132 ),
133 };
134 let mut header = format!("{{'descr': '{descr}', 'fortran_order': False, 'shape': {shape}, }}");
135 while (10 + header.len() + 1) % 64 != 0 {
137 header.push(' ');
138 }
139 header.push('\n');
140 let mut out = b"\x93NUMPY\x01\x00".to_vec();
141 out.extend((header.len() as u16).to_le_bytes());
142 out.extend(header.into_bytes());
143 out.extend(array_bytes(a)?);
144 std::fs::write(path.as_ref(), out)
145 .map_err(|e| Error::Io(format!("{}: {e}", path.as_ref().display())))
146}