use std::io::{Read, Write};
use std::path::Path;
use std::process::{Command, ExitCode};
use std::{env, fs, io};
use serde::Serialize;
use serde_json::ser::{PrettyFormatter, Serializer};
use serde_json::Value;
mod strip;
const USAGE: &str = "\
strip Jupyter notebook outputs (outputs, execution counts, transient metadata)
usage:
nbstrip FILE... rewrite files in place
nbstrip -t FILE... print stripped notebooks to stdout
nbstrip < in > out stdin to stdout (the git clean filter)
nbstrip install register as the current repository's filter for
*.ipynb — git (config + .git/info/attributes) or
Mercurial ([encode] pipe filter + precommit guard
hook in .hg/hgrc)
options:
-t, --textconv print stripped notebooks to stdout instead of
rewriting them in place
-h, --help print this help
-V, --version print the version
Cells or notebooks marked `keep_output` (metadata or cell tag) keep outputs.
";
const FLAG_TEXTCONV: &str = "--textconv";
const FLAG_TEXTCONV_SHORT: &str = "-t";
const FLAG_HELP: &str = "--help";
const FLAG_HELP_SHORT: &str = "-h";
const FLAG_VERSION: &str = "--version";
const FLAG_VERSION_SHORT: &str = "-V";
const CMD_INSTALL: &str = "install";
const FILTER_CLEAN_KEY: &str = "filter.nbstrip.clean";
const FILTER_SMUDGE_KEY: &str = "filter.nbstrip.smudge";
const FILTER_REQUIRED_KEY: &str = "filter.nbstrip.required";
const FILTER_SMUDGE_PASSTHROUGH: &str = "cat";
const ATTRIBUTES_LINE: &str = "*.ipynb filter=nbstrip";
const HG_ENCODE_SECTION: &str = "[encode]";
const HG_ENCODE_PATTERN: &str = "**.ipynb";
const HG_HOOKS_SECTION: &str = "[hooks]";
const HG_HOOK_KEY: &str = "precommit.nbstrip";
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("nbstrip: {e}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<(), String> {
let mut textconv = false;
let mut files = Vec::new();
for arg in env::args().skip(1) {
match arg.as_str() {
FLAG_TEXTCONV | FLAG_TEXTCONV_SHORT => textconv = true,
FLAG_HELP | FLAG_HELP_SHORT => {
print!("{USAGE}");
return Ok(());
}
FLAG_VERSION | FLAG_VERSION_SHORT => {
println!("nbstrip {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
_ if arg.starts_with('-') => {
return Err(format!("unknown flag `{arg}`\n\n{USAGE}"));
}
_ => files.push(arg),
}
}
if !textconv && files.len() == 1 && files[0] == CMD_INSTALL {
return install();
}
if files.is_empty() {
let mut input = String::new();
io::stdin()
.read_to_string(&mut input)
.map_err(|e| format!("reading stdin: {e}"))?;
let stripped = strip_to_string(&input)?;
io::stdout()
.write_all(stripped.as_bytes())
.map_err(|e| format!("writing stdout: {e}"))?;
return Ok(());
}
for file in &files {
let input = fs::read_to_string(file).map_err(|e| format!("reading {file}: {e}"))?;
let stripped = strip_to_string(&input).map_err(|e| format!("{file}: {e}"))?;
if textconv {
io::stdout()
.write_all(stripped.as_bytes())
.map_err(|e| format!("writing stdout: {e}"))?;
} else if stripped != input {
fs::write(file, stripped).map_err(|e| format!("writing {file}: {e}"))?;
}
}
Ok(())
}
fn strip_to_string(input: &str) -> Result<String, String> {
let mut nb: Value =
serde_json::from_str(input).map_err(|e| format!("not valid notebook JSON: {e}"))?;
strip::strip(&mut nb);
let mut buf = Vec::new();
let mut ser = Serializer::with_formatter(&mut buf, PrettyFormatter::with_indent(b" "));
nb.serialize(&mut ser)
.map_err(|e| format!("serializing notebook: {e}"))?;
let mut out = String::from_utf8(buf).map_err(|e| format!("serialized non-UTF-8: {e}"))?;
if input.ends_with('\n') {
out.push('\n');
}
Ok(out)
}
fn install() -> Result<(), String> {
let exe = env::current_exe().map_err(|e| format!("resolving own path: {e}"))?;
let exe = exe
.to_str()
.ok_or("this executable's path is not valid UTF-8")?
.to_owned();
if let Ok(git_dir) = cmd_stdout("git", &["rev-parse", "--absolute-git-dir"]) {
return install_git(&git_dir, &exe);
}
if let Ok(hg_root) = cmd_stdout("hg", &["root"]) {
return install_hg(&hg_root, &exe);
}
Err("not inside a git or Mercurial repository".to_owned())
}
fn install_git(git_dir: &str, exe: &str) -> Result<(), String> {
let clean_cmd = shell_quote(exe);
cmd_ok("git", &["config", FILTER_CLEAN_KEY, &clean_cmd])?;
cmd_ok(
"git",
&["config", FILTER_SMUDGE_KEY, FILTER_SMUDGE_PASSTHROUGH],
)?;
cmd_ok("git", &["config", FILTER_REQUIRED_KEY, "true"])?;
let attributes = Path::new(git_dir).join("info").join("attributes");
let existing = fs::read_to_string(&attributes).unwrap_or_default();
if existing.lines().any(|l| l.trim() == ATTRIBUTES_LINE) {
println!("attributes already present: {}", attributes.display());
} else {
if let Some(dir) = attributes.parent() {
fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
}
let newline = if existing.is_empty() || existing.ends_with('\n') {
""
} else {
"\n"
};
fs::write(
&attributes,
format!("{existing}{newline}{ATTRIBUTES_LINE}\n"),
)
.map_err(|e| format!("writing {}: {e}", attributes.display()))?;
println!("wrote {}: {ATTRIBUTES_LINE}", attributes.display());
}
println!("configured {FILTER_CLEAN_KEY} = {clean_cmd}");
println!("configured {FILTER_SMUDGE_KEY} = {FILTER_SMUDGE_PASSTHROUGH}");
println!("configured {FILTER_REQUIRED_KEY} = true");
println!("notebooks now strip on `git add`; the working tree keeps its outputs.");
Ok(())
}
fn install_hg(hg_root: &str, exe: &str) -> Result<(), String> {
let hgrc = Path::new(hg_root).join(".hg").join("hgrc");
let quoted_exe = shell_quote(exe);
let filter_line = format!("{HG_ENCODE_PATTERN} = pipe: {quoted_exe}");
let hook_line = format!("{HG_HOOK_KEY} = {quoted_exe} {FLAG_VERSION} >/dev/null");
let existing = fs::read_to_string(&hgrc).unwrap_or_default();
let mut lines: Vec<String> = existing.lines().map(str::to_owned).collect();
hgrc_upsert(
&mut lines,
HG_ENCODE_SECTION,
HG_ENCODE_PATTERN,
&filter_line,
);
hgrc_upsert(&mut lines, HG_HOOKS_SECTION, HG_HOOK_KEY, &hook_line);
fs::write(&hgrc, format!("{}\n", lines.join("\n")))
.map_err(|e| format!("writing {}: {e}", hgrc.display()))?;
println!("wrote {}:", hgrc.display());
println!(" {HG_ENCODE_SECTION}");
println!(" {filter_line}");
println!(" {HG_HOOKS_SECTION}");
println!(" {hook_line}");
println!("notebooks now strip on `hg commit`; the working directory keeps its outputs.");
println!("a commit aborts if the nbstrip binary goes missing (re-run install to re-wire).");
Ok(())
}
fn hgrc_upsert(lines: &mut Vec<String>, section: &str, key: &str, entry: &str) {
let mut in_section = false;
let mut insert_at = None; for (i, line) in lines.iter_mut().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_section = trimmed == section;
}
if in_section {
insert_at = Some(i + 1);
if trimmed.split('=').next().is_some_and(|k| k.trim() == key) {
entry.clone_into(line);
return;
}
}
}
if let Some(at) = insert_at {
lines.insert(at, entry.to_owned());
} else {
if !lines.is_empty() {
lines.push(String::new());
}
lines.push(section.to_owned());
lines.push(entry.to_owned());
}
}
fn cmd_stdout(cmd: &str, args: &[&str]) -> Result<String, String> {
let out = Command::new(cmd)
.args(args)
.output()
.map_err(|e| format!("running {cmd}: {e}"))?;
if !out.status.success() {
return Err(format!(
"{cmd} {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
}
fn cmd_ok(cmd: &str, args: &[&str]) -> Result<(), String> {
cmd_stdout(cmd, args).map(|_| ())
}
fn shell_quote(path: &str) -> String {
let safe = path
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b'+'));
if safe && !path.is_empty() {
path.to_owned()
} else {
format!("'{}'", path.replace('\'', r"'\''"))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::{shell_quote, strip_to_string};
const NOTEBOOK: &str = r#"{
"cells": [
{
"cell_type": "code",
"execution_count": 3,
"id": "aa11bb22",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": ["hi\n"]
}
],
"source": ["print('hi')"]
}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 5
}
"#;
#[test]
fn strips_and_matches_nbformat_style() {
let out = strip_to_string(NOTEBOOK).unwrap();
assert!(out.contains("\"outputs\": []"));
assert!(out.contains("\"execution_count\": null"));
assert!(out.starts_with("{\n \"cells\""));
assert!(out.ends_with("}\n"));
}
#[test]
fn stable_under_a_second_pass() {
let once = strip_to_string(NOTEBOOK).unwrap();
assert_eq!(strip_to_string(&once).unwrap(), once);
}
#[test]
fn rejects_garbage() {
assert!(strip_to_string("not json").is_err());
}
#[test]
fn shell_quoting() {
assert_eq!(
shell_quote("/usr/local/bin/nbstrip"),
"/usr/local/bin/nbstrip"
);
assert_eq!(
shell_quote("/opt/my tools/nbstrip"),
"'/opt/my tools/nbstrip'"
);
assert_eq!(shell_quote("/o'dd/nbstrip"), r"'/o'\''dd/nbstrip'");
}
}