pub mod rest;
use std::path::{Path, PathBuf};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DisplayItem {
pub mime: String,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ImageOutput {
pub path: PathBuf,
pub mime: String,
pub width: Option<u64>,
pub height: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct KernelError {
pub ename: String,
pub evalue: String,
pub traceback: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct CellRun {
pub stdout: String,
pub stderr: String,
pub results: Vec<DisplayItem>,
pub images: Vec<ImageOutput>,
pub error: Option<KernelError>,
pub execution_count: Option<i64>,
}
impl CellRun {
pub fn failed(&self) -> bool {
self.error.is_some()
}
}
pub trait PngSink: Send {
fn write_png(&mut self, execution_count: Option<i64>, bytes: &[u8]) -> anyhow::Result<PathBuf>;
}
#[cfg(feature = "kernel")]
pub struct DirPngSink {
dir: PathBuf,
}
#[cfg(feature = "kernel")]
impl DirPngSink {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
}
#[cfg(feature = "kernel")]
impl PngSink for DirPngSink {
fn write_png(&mut self, execution_count: Option<i64>, bytes: &[u8]) -> anyhow::Result<PathBuf> {
std::fs::create_dir_all(&self.dir)?;
let n = execution_count.unwrap_or(0);
let name = format!("cell-{n}-{}.png", uuid::Uuid::new_v4());
let path = self.dir.join(name);
std::fs::write(&path, bytes)?;
Ok(path)
}
}
pub const MIME_PNG: &str = "image/png";
pub struct Accumulator<'a> {
run: CellRun,
sink: &'a mut dyn PngSink,
idle: bool,
}
impl<'a> Accumulator<'a> {
pub fn new(sink: &'a mut dyn PngSink) -> Self {
Self {
run: CellRun::default(),
sink,
idle: false,
}
}
pub fn is_idle(&self) -> bool {
self.idle
}
pub fn feed(&mut self, msg: &serde_json::Value) -> anyhow::Result<()> {
let msg_type = msg
.get("header")
.and_then(|h| h.get("msg_type"))
.and_then(|t| t.as_str())
.or_else(|| msg.get("msg_type").and_then(|t| t.as_str()))
.unwrap_or("");
let content = msg.get("content").unwrap_or(&serde_json::Value::Null);
match msg_type {
"stream" => self.fold_stream(content),
"execute_result" => self.fold_output_bundle(content, true)?,
"display_data" => self.fold_output_bundle(content, false)?,
"error" => self.fold_error(content),
"status" => self.fold_status(content),
_ => {}
}
Ok(())
}
pub fn finish(self) -> CellRun {
self.run
}
fn fold_stream(&mut self, content: &serde_json::Value) {
let text = content.get("text").and_then(|t| t.as_str()).unwrap_or("");
match content.get("name").and_then(|n| n.as_str()) {
Some("stderr") => self.run.stderr.push_str(text),
_ => self.run.stdout.push_str(text),
}
}
fn fold_output_bundle(
&mut self,
content: &serde_json::Value,
is_execute_result: bool,
) -> anyhow::Result<()> {
if is_execute_result {
if let Some(n) = content.get("execution_count").and_then(|c| c.as_i64()) {
self.run.execution_count = Some(n);
}
}
let data = match content.get("data").and_then(|d| d.as_object()) {
Some(obj) => obj,
None => return Ok(()),
};
if let Some(png_b64) = data.get(MIME_PNG).and_then(|v| v.as_str()) {
let bytes = decode_png_base64(png_b64)?;
let (width, height) = png_dimensions_from_metadata(content);
let path = self
.sink
.write_png(self.run.execution_count, &bytes)
.map_err(|e| anyhow::anyhow!("failed to write PNG plot: {e}"))?;
self.run.images.push(ImageOutput {
path,
mime: MIME_PNG.to_string(),
width,
height,
});
}
let mut items: Vec<DisplayItem> = data
.iter()
.filter(|(mime, _)| mime.as_str() != MIME_PNG)
.filter_map(|(mime, value)| {
display_text(value).map(|text| DisplayItem {
mime: mime.clone(),
text,
})
})
.collect();
items.sort_by(|a, b| a.mime.cmp(&b.mime));
self.run.results.extend(items);
Ok(())
}
fn fold_error(&mut self, content: &serde_json::Value) {
let ename = content
.get("ename")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let evalue = content
.get("evalue")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let traceback = content
.get("traceback")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|line| line.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
self.run.error = Some(KernelError {
ename,
evalue,
traceback,
});
}
fn fold_status(&mut self, content: &serde_json::Value) {
if content
.get("execution_state")
.and_then(|s| s.as_str())
.map(|s| s == "idle")
.unwrap_or(false)
{
self.idle = true;
}
}
}
fn display_text(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Array(lines) => Some(
lines
.iter()
.filter_map(|l| l.as_str())
.collect::<Vec<_>>()
.concat(),
),
serde_json::Value::Null => None,
other => Some(other.to_string()),
}
}
fn png_dimensions_from_metadata(content: &serde_json::Value) -> (Option<u64>, Option<u64>) {
let meta = content
.get("metadata")
.and_then(|m| m.get(MIME_PNG))
.or_else(|| content.get("metadata"));
let width = meta.and_then(|m| m.get("width")).and_then(|w| w.as_u64());
let height = meta.and_then(|m| m.get("height")).and_then(|h| h.as_u64());
(width, height)
}
fn decode_png_base64(b64: &str) -> anyhow::Result<Vec<u8>> {
let cleaned: String = b64.chars().filter(|c| !c.is_whitespace()).collect();
base64_decode(&cleaned).map_err(|e| anyhow::anyhow!("invalid base64 in image/png payload: {e}"))
}
fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
fn val(c: u8) -> Result<u8, String> {
match c {
b'A'..=b'Z' => Ok(c - b'A'),
b'a'..=b'z' => Ok(c - b'a' + 26),
b'0'..=b'9' => Ok(c - b'0' + 52),
b'+' => Ok(62),
b'/' => Ok(63),
_ => Err(format!("invalid base64 byte: 0x{c:02x}")),
}
}
let bytes = s.as_bytes();
if !bytes.len().is_multiple_of(4) {
return Err(format!(
"base64 length {} is not a multiple of 4",
bytes.len()
));
}
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
for chunk in bytes.chunks(4) {
let pad = chunk.iter().rev().take_while(|&&b| b == b'=').count();
let b0 = val(chunk[0])?;
let b1 = val(chunk[1])?;
let b2 = if pad >= 2 { 0 } else { val(chunk[2])? };
let b3 = if pad >= 1 { 0 } else { val(chunk[3])? };
out.push((b0 << 2) | (b1 >> 4));
if pad < 2 {
out.push((b1 << 4) | (b2 >> 2));
}
if pad < 1 {
out.push((b2 << 6) | b3);
}
}
Ok(out)
}
#[cfg(feature = "kernel")]
#[async_trait::async_trait]
pub trait KernelClient: Send {
async fn run_cell(&self, code: &str) -> anyhow::Result<CellRun>;
}
pub fn plots_dir(data_dir: &Path) -> PathBuf {
data_dir.join("plots")
}
pub fn cell_run_to_nb_outputs(run: &CellRun) -> Vec<serde_json::Value> {
let mut outputs = Vec::new();
if !run.stdout.is_empty() {
outputs.push(serde_json::json!({
"output_type": "stream",
"name": "stdout",
"text": run.stdout,
}));
}
if !run.stderr.is_empty() {
outputs.push(serde_json::json!({
"output_type": "stream",
"name": "stderr",
"text": run.stderr,
}));
}
for item in &run.results {
outputs.push(serde_json::json!({
"output_type": "execute_result",
"data": { item.mime.clone(): item.text },
"metadata": {},
"execution_count": run.execution_count,
}));
}
for img in &run.images {
match std::fs::read(&img.path) {
Ok(bytes) => {
let mut data = serde_json::Map::new();
data.insert(
MIME_PNG.to_string(),
serde_json::json!(base64_encode(&bytes)),
);
let mut png_meta = serde_json::Map::new();
if let Some(w) = img.width {
png_meta.insert("width".to_string(), serde_json::json!(w));
}
if let Some(h) = img.height {
png_meta.insert("height".to_string(), serde_json::json!(h));
}
outputs.push(serde_json::json!({
"output_type": "display_data",
"data": serde_json::Value::Object(data),
"metadata": { MIME_PNG: serde_json::Value::Object(png_meta) },
}));
}
Err(e) => {
tracing::warn!(
path = %img.path.display(),
error = %e,
"cell_run_to_nb_outputs: skipping unreadable PNG plot when persisting cell"
);
}
}
}
if let Some(err) = &run.error {
outputs.push(serde_json::json!({
"output_type": "error",
"ename": err.ename,
"evalue": err.evalue,
"traceback": err.traceback,
}));
}
outputs
}
fn base64_encode(bytes: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b0 = chunk[0];
let b1 = chunk.get(1).copied().unwrap_or(0);
let b2 = chunk.get(2).copied().unwrap_or(0);
out.push(A[(b0 >> 2) as usize] as char);
out.push(A[(((b0 & 0b11) << 4) | (b1 >> 4)) as usize] as char);
out.push(if chunk.len() > 1 {
A[(((b1 & 0b1111) << 2) | (b2 >> 6)) as usize] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
A[(b2 & 0b111111) as usize] as char
} else {
'='
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
type Writes = Arc<Mutex<Vec<(Option<i64>, Vec<u8>)>>>;
struct RecordingSink {
writes: Writes,
}
impl PngSink for RecordingSink {
fn write_png(
&mut self,
execution_count: Option<i64>,
bytes: &[u8],
) -> anyhow::Result<PathBuf> {
self.writes
.lock()
.unwrap()
.push((execution_count, bytes.to_vec()));
Ok(PathBuf::from(format!(
"/plots/cell-{}-fixed.png",
execution_count.unwrap_or(0)
)))
}
}
fn header(msg_type: &str) -> serde_json::Value {
serde_json::json!({ "msg_type": msg_type })
}
fn png_b64() -> (String, Vec<u8>) {
let raw = vec![0x89u8, b'P', b'N', b'G'];
let encoded = encode_for_test(&raw);
(encoded, raw)
}
fn encode_for_test(bytes: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let b0 = chunk[0];
let b1 = chunk.get(1).copied().unwrap_or(0);
let b2 = chunk.get(2).copied().unwrap_or(0);
out.push(A[(b0 >> 2) as usize] as char);
out.push(A[(((b0 & 0b11) << 4) | (b1 >> 4)) as usize] as char);
out.push(if chunk.len() > 1 {
A[(((b1 & 0b1111) << 2) | (b2 >> 6)) as usize] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
A[(b2 & 0b111111) as usize] as char
} else {
'='
});
}
out
}
#[test]
fn base64_decode_round_trips_rfc_vectors() {
assert_eq!(base64_decode("").unwrap(), b"");
assert_eq!(base64_decode("Zg==").unwrap(), b"f");
assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
assert_eq!(base64_decode("Zm9v").unwrap(), b"foo");
assert_eq!(base64_decode("Zm9vYg==").unwrap(), b"foob");
assert_eq!(base64_decode("Zm9vYmE=").unwrap(), b"fooba");
assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
}
#[test]
fn base64_decode_rejects_bad_input() {
assert!(base64_decode("Zg=").is_err()); assert!(base64_decode("Z!==").is_err()); }
#[test]
fn decode_png_base64_strips_embedded_newlines() {
let (b64, raw) = png_b64();
let wrapped = format!("{}\n{}", &b64[..4], &b64[4..]);
assert_eq!(decode_png_base64(&wrapped).unwrap(), raw);
}
#[test]
fn fold_stream_splits_stdout_and_stderr() {
let mut sink = RecordingSink {
writes: Arc::new(Mutex::new(Vec::new())),
};
let mut acc = Accumulator::new(&mut sink);
acc.feed(&serde_json::json!({
"header": header("stream"),
"content": { "name": "stdout", "text": "hello " }
}))
.unwrap();
acc.feed(&serde_json::json!({
"header": header("stream"),
"content": { "name": "stdout", "text": "world\n" }
}))
.unwrap();
acc.feed(&serde_json::json!({
"header": header("stream"),
"content": { "name": "stderr", "text": "a warning\n" }
}))
.unwrap();
let run = acc.finish();
assert_eq!(run.stdout, "hello world\n");
assert_eq!(run.stderr, "a warning\n");
}
#[test]
fn fold_execute_result_records_text_and_execution_count() {
let mut sink = RecordingSink {
writes: Arc::new(Mutex::new(Vec::new())),
};
let mut acc = Accumulator::new(&mut sink);
acc.feed(&serde_json::json!({
"header": header("execute_result"),
"content": {
"execution_count": 7,
"data": {
"text/plain": "42",
"text/html": ["<b>", "42", "</b>"]
}
}
}))
.unwrap();
let run = acc.finish();
assert_eq!(run.execution_count, Some(7));
assert_eq!(
run.results,
vec![
DisplayItem {
mime: "text/html".into(),
text: "<b>42</b>".into()
},
DisplayItem {
mime: "text/plain".into(),
text: "42".into()
},
]
);
assert!(run.images.is_empty());
}
#[test]
fn fold_display_data_with_png_writes_to_sink_never_inlines() {
let writes = Arc::new(Mutex::new(Vec::new()));
let mut sink = RecordingSink {
writes: writes.clone(),
};
let (b64, raw) = png_b64();
let mut acc = Accumulator::new(&mut sink);
acc.feed(&serde_json::json!({
"header": header("execute_result"),
"content": { "execution_count": 3, "data": { "text/plain": "<Figure>" } }
}))
.unwrap();
acc.feed(&serde_json::json!({
"header": header("display_data"),
"content": {
"data": { "image/png": b64, "text/plain": "<Figure size 640x480>" },
"metadata": { "image/png": { "width": 640, "height": 480 } }
}
}))
.unwrap();
let run = acc.finish();
let w = writes.lock().unwrap();
assert_eq!(w.len(), 1);
assert_eq!(w[0].0, Some(3));
assert_eq!(w[0].1, raw);
assert_eq!(run.images.len(), 1);
assert_eq!(run.images[0].mime, MIME_PNG);
assert_eq!(run.images[0].width, Some(640));
assert_eq!(run.images[0].height, Some(480));
assert!(run.results.iter().all(|d| d.mime != MIME_PNG));
assert_eq!(
run.results
.iter()
.filter(|d| d.mime == "text/plain")
.count(),
2
);
}
#[test]
fn fold_error_records_exception() {
let mut sink = RecordingSink {
writes: Arc::new(Mutex::new(Vec::new())),
};
let mut acc = Accumulator::new(&mut sink);
acc.feed(&serde_json::json!({
"header": header("error"),
"content": {
"ename": "NameError",
"evalue": "name 'foo' is not defined",
"traceback": ["Traceback...", "NameError: name 'foo' is not defined"]
}
}))
.unwrap();
let run = acc.finish();
assert!(run.failed());
let err = run.error.unwrap();
assert_eq!(err.ename, "NameError");
assert_eq!(err.evalue, "name 'foo' is not defined");
assert_eq!(err.traceback.len(), 2);
}
#[test]
fn fold_status_idle_terminates() {
let mut sink = RecordingSink {
writes: Arc::new(Mutex::new(Vec::new())),
};
let mut acc = Accumulator::new(&mut sink);
assert!(!acc.is_idle());
acc.feed(&serde_json::json!({
"header": header("status"),
"content": { "execution_state": "busy" }
}))
.unwrap();
assert!(!acc.is_idle(), "busy must not terminate");
acc.feed(&serde_json::json!({
"header": header("status"),
"content": { "execution_state": "idle" }
}))
.unwrap();
assert!(acc.is_idle(), "idle must terminate");
}
#[test]
fn unknown_msg_type_is_noop() {
let mut sink = RecordingSink {
writes: Arc::new(Mutex::new(Vec::new())),
};
let mut acc = Accumulator::new(&mut sink);
acc.feed(&serde_json::json!({
"header": header("execute_input"),
"content": { "code": "1+1", "execution_count": 1 }
}))
.unwrap();
let run = acc.finish();
assert_eq!(run, CellRun::default());
}
#[test]
fn full_sequence_folds_to_cellrun() {
let mut sink = RecordingSink {
writes: Arc::new(Mutex::new(Vec::new())),
};
let mut acc = Accumulator::new(&mut sink);
for msg in [
serde_json::json!({ "header": header("status"), "content": { "execution_state": "busy" } }),
serde_json::json!({ "header": header("stream"), "content": { "name": "stdout", "text": "ok\n" } }),
serde_json::json!({ "header": header("execute_result"), "content": { "execution_count": 1, "data": { "text/plain": "2" } } }),
serde_json::json!({ "header": header("status"), "content": { "execution_state": "idle" } }),
] {
acc.feed(&msg).unwrap();
}
assert!(acc.is_idle());
let run = acc.finish();
assert_eq!(run.stdout, "ok\n");
assert_eq!(run.execution_count, Some(1));
assert_eq!(
run.results,
vec![DisplayItem {
mime: "text/plain".into(),
text: "2".into()
}]
);
assert!(!run.failed());
}
#[test]
fn cellrun_serializes_stably() {
let run = CellRun {
stdout: "hi".into(),
stderr: String::new(),
results: vec![DisplayItem {
mime: "text/plain".into(),
text: "1".into(),
}],
images: vec![ImageOutput {
path: PathBuf::from("/plots/cell-1-x.png"),
mime: MIME_PNG.into(),
width: Some(640),
height: Some(480),
}],
error: None,
execution_count: Some(1),
};
let json = serde_json::to_value(&run).unwrap();
assert_eq!(json["stdout"], "hi");
assert_eq!(json["results"][0]["mime"], "text/plain");
assert_eq!(json["images"][0]["width"], 640);
assert_eq!(json["execution_count"], 1);
}
#[test]
fn plots_dir_is_under_data_dir() {
assert_eq!(
plots_dir(Path::new("/ws/.newt-data")),
Path::new("/ws/.newt-data/plots")
);
}
#[test]
fn png_written_to_tempdir_has_exact_bytes() {
let dir = tempfile::tempdir().unwrap();
struct DiskSink {
dir: PathBuf,
last: Option<PathBuf>,
}
impl PngSink for DiskSink {
fn write_png(&mut self, n: Option<i64>, bytes: &[u8]) -> anyhow::Result<PathBuf> {
std::fs::create_dir_all(&self.dir)?;
let p = self.dir.join(format!("cell-{}-test.png", n.unwrap_or(0)));
std::fs::write(&p, bytes)?;
self.last = Some(p.clone());
Ok(p)
}
}
let mut sink = DiskSink {
dir: dir.path().join("plots"),
last: None,
};
let (b64, raw) = png_b64();
{
let mut acc = Accumulator::new(&mut sink);
acc.feed(&serde_json::json!({
"header": header("display_data"),
"content": { "data": { "image/png": b64 } }
}))
.unwrap();
let run = acc.finish();
assert_eq!(run.images.len(), 1);
}
let written = sink.last.unwrap();
assert!(written.exists());
assert_eq!(std::fs::read(&written).unwrap(), raw);
}
#[test]
fn base64_encode_matches_rfc_vectors_and_round_trips() {
assert_eq!(base64_encode(b""), "");
assert_eq!(base64_encode(b"f"), "Zg==");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_encode(b"foo"), "Zm9v");
assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
let bytes = vec![0x89u8, b'P', b'N', b'G', 0x00, 0xff, 0x10];
assert_eq!(base64_decode(&base64_encode(&bytes)).unwrap(), bytes);
}
#[test]
fn cell_run_to_nb_outputs_maps_streams_results_and_error() {
let run = CellRun {
stdout: "out\n".into(),
stderr: "warn\n".into(),
results: vec![DisplayItem {
mime: "text/plain".into(),
text: "42".into(),
}],
images: vec![],
error: Some(KernelError {
ename: "ValueError".into(),
evalue: "bad".into(),
traceback: vec!["Traceback...".into(), "ValueError: bad".into()],
}),
execution_count: Some(9),
};
let outputs = cell_run_to_nb_outputs(&run);
assert_eq!(outputs.len(), 4);
assert_eq!(outputs[0]["output_type"], "stream");
assert_eq!(outputs[0]["name"], "stdout");
assert_eq!(outputs[0]["text"], "out\n");
assert_eq!(outputs[1]["name"], "stderr");
assert_eq!(outputs[2]["output_type"], "execute_result");
assert_eq!(outputs[2]["data"]["text/plain"], "42");
assert_eq!(outputs[2]["execution_count"], 9);
assert_eq!(outputs[2]["metadata"], serde_json::json!({}));
assert_eq!(outputs[3]["output_type"], "error");
assert_eq!(outputs[3]["ename"], "ValueError");
assert_eq!(outputs[3]["evalue"], "bad");
assert_eq!(outputs[3]["traceback"].as_array().unwrap().len(), 2);
}
#[test]
fn cell_run_to_nb_outputs_skips_empty_streams() {
let run = CellRun {
results: vec![DisplayItem {
mime: "text/html".into(),
text: "<b>1</b>".into(),
}],
execution_count: Some(1),
..Default::default()
};
let outputs = cell_run_to_nb_outputs(&run);
assert_eq!(outputs.len(), 1);
assert_eq!(outputs[0]["output_type"], "execute_result");
assert_eq!(outputs[0]["data"]["text/html"], "<b>1</b>");
}
#[test]
fn cell_run_to_nb_outputs_rereads_png_into_display_data_base64() {
let dir = tempfile::tempdir().unwrap();
let png_path = dir.path().join("cell-5-x.png");
let raw = vec![0x89u8, b'P', b'N', b'G', 0x0d, 0x0a];
std::fs::write(&png_path, &raw).unwrap();
let run = CellRun {
images: vec![ImageOutput {
path: png_path,
mime: MIME_PNG.into(),
width: Some(640),
height: Some(480),
}],
execution_count: Some(5),
..Default::default()
};
let outputs = cell_run_to_nb_outputs(&run);
assert_eq!(outputs.len(), 1);
let out = &outputs[0];
assert_eq!(out["output_type"], "display_data");
let b64 = out["data"][MIME_PNG].as_str().unwrap();
assert_eq!(base64_decode(b64).unwrap(), raw);
assert_eq!(out["metadata"][MIME_PNG]["width"], 640);
assert_eq!(out["metadata"][MIME_PNG]["height"], 480);
}
#[test]
fn cell_run_to_nb_outputs_skips_missing_png_without_panic() {
let run = CellRun {
images: vec![ImageOutput {
path: PathBuf::from("/no/such/plot/cell-1-gone.png"),
mime: MIME_PNG.into(),
width: None,
height: None,
}],
..Default::default()
};
let outputs = cell_run_to_nb_outputs(&run);
assert!(
outputs.is_empty(),
"an unreadable PNG is skipped, not panicked on"
);
}
#[test]
fn cell_run_to_nb_outputs_png_without_dimensions_omits_size_metadata() {
let dir = tempfile::tempdir().unwrap();
let png_path = dir.path().join("p.png");
std::fs::write(&png_path, b"\x89PNG").unwrap();
let run = CellRun {
images: vec![ImageOutput {
path: png_path,
mime: MIME_PNG.into(),
width: None,
height: None,
}],
..Default::default()
};
let outputs = cell_run_to_nb_outputs(&run);
assert_eq!(outputs.len(), 1);
assert_eq!(outputs[0]["metadata"][MIME_PNG], serde_json::json!({}));
}
#[test]
fn cell_run_to_nb_outputs_empty_run_is_empty() {
assert!(cell_run_to_nb_outputs(&CellRun::default()).is_empty());
}
}