use rand::{RngExt, SeedableRng};
use serde::Serialize;
use serde_json::error::Category;
use std::cmp::Ordering;
use std::collections::HashSet;
use std::fs::File;
use std::io;
use std::io::{BufReader, Cursor, Read, Seek, SeekFrom, Write};
use std::path::Path;
use std::sync::OnceLock;
use thiserror::Error;
use ruff_diagnostics::{SourceMap, SourceMarker};
use ruff_source_file::{OneIndexed, UniversalNewlineIterator};
use ruff_text_size::{TextRange, TextSize};
use crate::cell::CellOffsets;
use crate::index::NotebookIndex;
use crate::schema::{Cell, RawNotebook, SortAlphabetically, SourceValue};
use crate::{CellMetadata, CellStart, RawNotebookMetadata, SYNTHETIC_CELL_SEPARATOR, schema};
pub fn round_trip(path: &Path) -> anyhow::Result<String> {
let mut notebook = Notebook::from_path(path).map_err(|err| {
anyhow::anyhow!(
"Failed to read notebook file `{}`: {:?}",
path.display(),
err
)
})?;
let code = notebook.source_code().to_string();
let needs_rebuild = notebook.update_cell_content(&code);
debug_assert!(
!needs_rebuild,
"round-tripping unchanged source cannot remove a synthetic cell separator"
);
let mut writer = Vec::new();
notebook.write(&mut writer)?;
Ok(String::from_utf8(writer)?)
}
#[derive(Error, Debug)]
pub enum NotebookError {
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Json(serde_json::Error),
#[error(
"Expected a Jupyter Notebook, which must be internally stored as JSON, but this file isn't valid JSON: {0}"
)]
InvalidJson(serde_json::Error),
#[error("This file does not match the schema expected of Jupyter Notebooks: {0}")]
InvalidSchema(serde_json::Error),
#[error("Expected Jupyter Notebook format 4, found: {0}")]
InvalidFormat(i64),
}
#[derive(Clone, Debug)]
pub struct Notebook {
source_code: String,
index: OnceLock<NotebookIndex>,
raw: RawNotebook,
cell_offsets: CellOffsets,
valid_code_cells: Vec<u32>,
trailing_newline: bool,
}
impl Notebook {
pub fn from_path(path: &Path) -> Result<Self, NotebookError> {
Self::from_reader(BufReader::new(File::open(path)?))
}
pub fn from_source_code(source_code: &str) -> Result<Self, NotebookError> {
Self::from_reader(Cursor::new(source_code))
}
fn from_reader<R>(mut reader: R) -> Result<Self, NotebookError>
where
R: Read + Seek,
{
let trailing_newline = reader.seek(SeekFrom::End(-1)).is_ok_and(|_| {
let mut buf = [0; 1];
reader.read_exact(&mut buf).is_ok_and(|()| buf[0] == b'\n')
});
reader.rewind()?;
let raw_notebook: RawNotebook = match serde_json::from_reader(reader.by_ref()) {
Ok(notebook) => notebook,
Err(err) => {
return Err(match err.classify() {
Category::Io => NotebookError::Json(err),
Category::Syntax | Category::Eof => NotebookError::InvalidJson(err),
Category::Data => {
NotebookError::InvalidSchema(err)
}
});
}
};
Self::from_raw_notebook(raw_notebook, trailing_newline)
}
pub fn from_raw_notebook(
mut raw_notebook: RawNotebook,
trailing_newline: bool,
) -> Result<Self, NotebookError> {
if raw_notebook.nbformat != 4 {
return Err(NotebookError::InvalidFormat(raw_notebook.nbformat));
}
let valid_code_cells = raw_notebook
.cells
.iter()
.enumerate()
.filter(|(_, cell)| cell.is_valid_python_code_cell())
.map(|(cell_index, _)| u32::try_from(cell_index).unwrap())
.collect::<Vec<_>>();
if raw_notebook.nbformat == 4 && raw_notebook.nbformat_minor >= 5 {
let mut rng = rand::rngs::StdRng::seed_from_u64(0);
let mut existing_ids = HashSet::new();
for cell in &raw_notebook.cells {
let id = match cell {
Cell::Code(cell) => &cell.id,
Cell::Markdown(cell) => &cell.id,
Cell::Raw(cell) => &cell.id,
};
if let Some(id) = id {
existing_ids.insert(id.clone());
}
}
for cell in &mut raw_notebook.cells {
let id = match cell {
Cell::Code(cell) => &mut cell.id,
Cell::Markdown(cell) => &mut cell.id,
Cell::Raw(cell) => &mut cell.id,
};
if id.is_none() {
loop {
let new_id = uuid::Builder::from_random_bytes(rng.random())
.into_uuid()
.as_simple()
.to_string();
if existing_ids.insert(new_id.clone()) {
*id = Some(new_id);
break;
}
}
}
}
}
let (source_code, cell_offsets) =
Self::source_code_and_cell_offsets(&raw_notebook, &valid_code_cells);
Ok(Self {
raw: raw_notebook,
index: OnceLock::new(),
source_code,
cell_offsets,
valid_code_cells,
trailing_newline,
})
}
pub fn empty() -> Self {
Self::from_raw_notebook(
RawNotebook {
cells: vec![schema::Cell::Code(schema::CodeCell {
execution_count: None,
id: None,
metadata: CellMetadata::default(),
outputs: vec![],
source: schema::SourceValue::String(String::default()),
})],
metadata: RawNotebookMetadata::default(),
nbformat: 4,
nbformat_minor: 5,
},
false,
)
.unwrap()
}
fn source_code_and_cell_offsets(
raw_notebook: &RawNotebook,
valid_code_cells: &[u32],
) -> (String, CellOffsets) {
let mut source_code = String::new();
let mut cell_offsets = CellOffsets::with_capacity(valid_code_cells.len() + 1);
cell_offsets.push(TextSize::from(0));
for &idx in valid_code_cells {
match raw_notebook.cells[idx as usize].source() {
SourceValue::String(string) => source_code.push_str(string),
SourceValue::StringArray(string_array) => {
for string in string_array {
source_code.push_str(string);
}
}
}
source_code.push(SYNTHETIC_CELL_SEPARATOR);
cell_offsets.push(TextSize::of(&source_code));
}
if valid_code_cells.is_empty() {
source_code.push(SYNTHETIC_CELL_SEPARATOR);
}
(source_code, cell_offsets)
}
fn update_cell_offsets(&mut self, source_map: &SourceMap) {
let mut last_marker: Option<&SourceMarker> = None;
for (index, offset) in self.cell_offsets.iter_mut().skip(1).rev().enumerate() {
let closest_marker = match last_marker {
Some(marker) if marker.source() < *offset => marker,
_ => {
let mut markers = source_map.markers().iter().rev();
let Some(marker) = markers.find(|marker| marker.source() <= *offset) else {
break;
};
let marker = if index > 0 && marker.source() == *offset {
markers
.take_while(|marker| marker.source() == *offset)
.last()
.unwrap_or(marker)
} else {
marker
};
last_marker = Some(marker);
marker
}
};
match closest_marker.source().cmp(&closest_marker.dest()) {
Ordering::Less => *offset += closest_marker.dest() - closest_marker.source(),
Ordering::Greater => *offset -= closest_marker.source() - closest_marker.dest(),
Ordering::Equal => (),
}
}
}
fn update_cell_content(&mut self, transformed: &str) -> bool {
let mut missing_separator = false;
for (&idx, &[start, end]) in self
.valid_code_cells
.iter()
.zip(self.cell_offsets.array_windows::<2>())
{
let cell_content = transformed
.get(start.to_usize()..end.to_usize())
.unwrap_or_else(|| {
panic!(
"Transformed content out of bounds ({start:?}..{end:?}) for cell at {idx:?}"
);
});
missing_separator |= !cell_content.ends_with(SYNTHETIC_CELL_SEPARATOR);
self.raw.cells[idx as usize].set_source(SourceValue::StringArray(
UniversalNewlineIterator::from(
cell_content.strip_suffix('\n').unwrap_or(cell_content),
)
.map(|line| line.as_full_str().to_string())
.collect::<Vec<_>>(),
));
}
missing_separator
}
fn build_index(&self) -> NotebookIndex {
let mut cell_starts = Vec::with_capacity(self.valid_code_cells.len());
let mut current_row = OneIndexed::MIN;
for (&cell_index, range) in self.valid_code_cells.iter().zip(self.cell_offsets.ranges()) {
let raw_cell_index = cell_index as usize;
cell_starts.push(CellStart {
start_row: current_row,
raw_cell_index: OneIndexed::from_zero_indexed(raw_cell_index),
});
let line_count = UniversalNewlineIterator::from(&self.source_code[range]).count();
current_row = current_row.saturating_add(line_count);
}
NotebookIndex { cell_starts }
}
pub fn source_code(&self) -> &str {
&self.source_code
}
pub fn index(&self) -> &NotebookIndex {
self.index.get_or_init(|| self.build_index())
}
pub fn into_index(mut self) -> NotebookIndex {
self.index.take().unwrap_or_else(|| self.build_index())
}
pub fn cell_offsets(&self) -> &CellOffsets {
&self.cell_offsets
}
pub fn cell_offset(&self, cell: OneIndexed) -> Option<TextSize> {
self.cell_offsets.get(cell.to_zero_indexed()).copied()
}
pub fn cell_range(&self, cell: OneIndexed) -> Option<TextRange> {
let start = self.cell_offsets.get(cell.to_zero_indexed()).copied()?;
let end = self.cell_offsets.get(cell.to_zero_indexed() + 1).copied()?;
Some(TextRange::new(start, end))
}
pub fn trailing_newline(&self) -> bool {
self.trailing_newline
}
pub fn update(&mut self, source_map: &SourceMap, transformed: String) {
self.index.take();
self.update_cell_offsets(source_map);
let needs_rebuild = self.update_cell_content(&transformed);
if needs_rebuild {
(self.source_code, self.cell_offsets) =
Self::source_code_and_cell_offsets(&self.raw, &self.valid_code_cells);
} else {
self.source_code = transformed;
}
}
pub fn cells(&self) -> &[Cell] {
&self.raw.cells
}
pub fn metadata(&self) -> &RawNotebookMetadata {
&self.raw.metadata
}
pub fn is_python_notebook(&self) -> bool {
if let Some(language_info) = self.raw.metadata.language_info.as_ref() {
return language_info.name == "python";
}
if let Some(kernel_spec) = self.raw.metadata.kernelspec.as_ref() {
return kernel_spec.language.as_deref() == Some("python");
}
true
}
pub fn write(&self, writer: &mut dyn Write) -> Result<(), NotebookError> {
let formatter = serde_json::ser::PrettyFormatter::with_indent(b" ");
let mut serializer = serde_json::Serializer::with_formatter(writer, formatter);
SortAlphabetically(&self.raw)
.serialize(&mut serializer)
.map_err(NotebookError::Json)?;
if self.trailing_newline {
writeln!(serializer.into_inner())?;
}
Ok(())
}
}
impl PartialEq for Notebook {
fn eq(&self, other: &Self) -> bool {
self.trailing_newline == other.trailing_newline && self.raw == other.raw
}
}
impl Eq for Notebook {}
#[cfg(test)]
mod tests {
use std::path::Path;
use anyhow::Result;
use test_case::test_case;
use ruff_diagnostics::SourceMap;
use ruff_source_file::OneIndexed;
use ruff_text_size::TextSize;
use crate::{Cell, CellStart, Notebook, NotebookError, NotebookIndex};
fn notebook_path(path: impl AsRef<Path>) -> std::path::PathBuf {
Path::new("./resources/test/fixtures/jupyter").join(path)
}
#[test_case("valid.ipynb", true)]
#[test_case("R.ipynb", false)]
#[test_case("kernelspec_language.ipynb", true)]
fn is_python_notebook(filename: &str, expected: bool) {
let notebook = Notebook::from_path(¬ebook_path(filename)).unwrap();
assert_eq!(notebook.is_python_notebook(), expected);
}
#[test]
fn test_invalid() {
assert!(matches!(
Notebook::from_path(¬ebook_path("invalid_extension.ipynb")),
Err(NotebookError::InvalidJson(_))
));
assert!(matches!(
Notebook::from_path(¬ebook_path("not_json.ipynb")),
Err(NotebookError::InvalidJson(_))
));
assert!(matches!(
Notebook::from_path(¬ebook_path("wrong_schema.ipynb")),
Err(NotebookError::InvalidSchema(_))
));
}
#[test]
fn empty_notebook() {
let notebook = Notebook::empty();
assert_eq!(notebook.source_code(), "\n");
}
#[test_case("markdown", false)]
#[test_case("only_magic", true)]
#[test_case("code_and_magic", true)]
#[test_case("only_code", true)]
#[test_case("cell_magic", false)]
#[test_case("valid_cell_magic", true)]
#[test_case("automagic", false)]
#[test_case("automagic_assignment", true)]
#[test_case("automagics", false)]
#[test_case("automagic_before_code", false)]
#[test_case("automagic_after_code", true)]
#[test_case("unicode_magic_gh9145", true)]
#[test_case("vscode_language_id_python", true)]
#[test_case("vscode_language_id_javascript", false)]
fn test_is_valid_python_code_cell(cell: &str, expected: bool) -> Result<()> {
fn read_jupyter_cell(path: impl AsRef<Path>) -> Result<Cell> {
let path = notebook_path("cell").join(path);
let source_code = std::fs::read_to_string(path)?;
Ok(serde_json::from_str(&source_code)?)
}
assert_eq!(
read_jupyter_cell(format!("{cell}.json"))?.is_valid_python_code_cell(),
expected
);
Ok(())
}
#[test]
fn test_concat_notebook() -> Result<(), NotebookError> {
let notebook = Notebook::from_path(¬ebook_path("valid.ipynb"))?;
assert_eq!(
notebook.source_code,
r#"def unused_variable():
x = 1
y = 2
print(f"cell one: {y}")
unused_variable()
def mutable_argument(z=set()):
print(f"cell two: {z}")
mutable_argument()
print("after empty cells")
"#
);
assert_eq!(
notebook.index(),
&NotebookIndex {
cell_starts: vec![
CellStart {
start_row: OneIndexed::MIN,
raw_cell_index: OneIndexed::MIN
},
CellStart {
start_row: OneIndexed::from_zero_indexed(6),
raw_cell_index: OneIndexed::from_zero_indexed(2)
},
CellStart {
start_row: OneIndexed::from_zero_indexed(11),
raw_cell_index: OneIndexed::from_zero_indexed(4)
},
CellStart {
start_row: OneIndexed::from_zero_indexed(12),
raw_cell_index: OneIndexed::from_zero_indexed(6)
},
CellStart {
start_row: OneIndexed::from_zero_indexed(14),
raw_cell_index: OneIndexed::from_zero_indexed(7)
}
],
}
);
assert_eq!(
notebook.cell_offsets().as_ref(),
&[
0.into(),
90.into(),
168.into(),
169.into(),
171.into(),
198.into()
]
);
Ok(())
}
#[test]
fn index_fragmented_source_array() -> Result<(), NotebookError> {
let notebook = Notebook::from_source_code(
r##"{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": ["p", "a", "s", "s", " ", " ", " "]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": ["# snapshot\n", "x = 1"]
}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 4
}"##,
)?;
assert_eq!(notebook.source_code(), "pass \n# snapshot\nx = 1\n");
assert_eq!(
notebook.index(),
&NotebookIndex {
cell_starts: vec![
CellStart {
start_row: OneIndexed::MIN,
raw_cell_index: OneIndexed::MIN,
},
CellStart {
start_row: OneIndexed::from_zero_indexed(1),
raw_cell_index: OneIndexed::from_zero_indexed(1),
},
],
}
);
Ok(())
}
#[test]
fn update_restores_separators_for_empty_cells() -> Result<(), NotebookError> {
let mut notebook = Notebook::from_source_code(
r##"{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": ["import os"]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": ["import sys"]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": ["x = 1"]
}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 4
}"##,
)?;
let mut source_map = SourceMap::default();
source_map.push_marker(0.into(), 0.into());
source_map.push_marker(10.into(), 0.into());
source_map.push_marker(21.into(), 0.into());
notebook.update(&source_map, "x = 1\n".to_string());
assert_eq!(notebook.source_code(), "\n\nx = 1\n");
assert_eq!(
notebook.cell_offsets().as_ref(),
&[0.into(), 1.into(), 2.into(), 8.into()]
);
assert_eq!(
notebook.index(),
&NotebookIndex {
cell_starts: vec![
CellStart {
start_row: OneIndexed::MIN,
raw_cell_index: OneIndexed::MIN,
},
CellStart {
start_row: OneIndexed::from_zero_indexed(1),
raw_cell_index: OneIndexed::from_zero_indexed(1),
},
CellStart {
start_row: OneIndexed::from_zero_indexed(2),
raw_cell_index: OneIndexed::from_zero_indexed(2),
},
],
}
);
Ok(())
}
fn two_cell_notebook() -> Result<Notebook, NotebookError> {
Notebook::from_source_code(
r##"{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": ["x = 1"]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": ["x.method(inplace=True)"]
}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 4
}"##,
)
}
#[test]
fn update_keeps_insertion_at_cell_start_in_that_cell() -> Result<(), NotebookError> {
let mut notebook = two_cell_notebook()?;
let mut source_map = SourceMap::default();
source_map.push_marker(6.into(), 6.into());
source_map.push_marker(6.into(), 10.into());
notebook.update(
&source_map,
"x = 1\nx = x.method(inplace=True)\n".to_string(),
);
assert_eq!(
notebook.source_code(),
"x = 1\nx = x.method(inplace=True)\n"
);
assert_eq!(
notebook.cell_offsets().as_ref(),
&[0.into(), 6.into(), 33.into()]
);
Ok(())
}
#[test]
fn update_keeps_insertion_at_end_in_non_final_cell() -> Result<(), NotebookError> {
let mut notebook = two_cell_notebook()?;
let mut source_map = SourceMap::default();
source_map.push_marker(5.into(), 5.into());
source_map.push_marker(5.into(), 16.into());
notebook.update(
&source_map,
"x = 1 # comment\nx.method(inplace=True)\n".to_string(),
);
assert_eq!(
notebook.source_code(),
"x = 1 # comment\nx.method(inplace=True)\n"
);
assert_eq!(
notebook.cell_offsets().as_ref(),
&[0.into(), 17.into(), 40.into()]
);
Ok(())
}
#[test]
fn update_keeps_insertion_at_end_in_last_cell() {
let mut notebook = Notebook::empty();
let end = TextSize::of(notebook.source_code());
let insertion = "# comment\n";
let transformed = format!("{}{insertion}", notebook.source_code());
let mut source_map = SourceMap::default();
source_map.push_marker(end, end);
source_map.push_marker(end, end + TextSize::of(insertion));
notebook.update(&source_map, transformed.clone());
assert_eq!(
notebook.cell_offsets().last().copied(),
Some(TextSize::of(&transformed))
);
assert_eq!(notebook.cells()[0].source().to_string(), "\n# comment");
}
#[test_case("vscode_language_id.ipynb")]
#[test_case("kernelspec_language.ipynb")]
fn round_trip(filename: &str) {
let path = notebook_path(filename);
let expected = std::fs::read_to_string(&path).unwrap();
let actual = super::round_trip(&path).unwrap();
assert_eq!(actual, expected);
}
}