Skip to content

Commit 3286ae0

Browse files
wpk-nist-govwpk
andauthored
Optionally write out executed notebook in jupyter-execute (#307)
* fix: Actually write out executed notebook in jupyter-execute Fixes #306. Now actually write out the executed notebook to the same path as the input. * feat: Added cli options to save executed notebook This commit adds two options to optionally save the executed notebook. * `--inplace`: Save the executed notebook to the input notebook path * `--output`: Save the executed notebook to `output`. This option can take a pattern like `{notebook_name}-new`, where `notebook_name` is the name of the input notebook without extension `.ipynb`. Also, the output location is always relative to the input notebook location. * chore: update mocker for opening files to remove error --------- Co-authored-by: wpk <[email protected]>
1 parent c788781 commit 3286ae0

File tree

2 files changed

+241
-6
lines changed

2 files changed

+241
-6
lines changed

nbclient/cli.py

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
from __future__ import annotations
33

44
import logging
5-
import pathlib
65
import sys
76
import typing
7+
from pathlib import Path
88
from textwrap import dedent
99

1010
import nbformat
@@ -22,6 +22,7 @@
2222
"timeout": "NbClientApp.timeout",
2323
"startup_timeout": "NbClientApp.startup_timeout",
2424
"kernel_name": "NbClientApp.kernel_name",
25+
"output": "NbClientApp.output_base",
2526
}
2627

2728
nbclient_flags: dict[str, typing.Any] = {
@@ -33,6 +34,14 @@
3334
},
3435
"Errors are ignored and execution is continued until the end of the notebook.",
3536
),
37+
"inplace": (
38+
{
39+
"NbClientApp": {
40+
"inplace": True,
41+
},
42+
},
43+
"Overwrite input notebook with executed results.",
44+
),
3645
}
3746

3847

@@ -98,6 +107,29 @@ class NbClientApp(JupyterApp):
98107
"""
99108
),
100109
).tag(config=True)
110+
inplace = Bool(
111+
False,
112+
help=dedent(
113+
"""
114+
Default is execute notebook without writing the newly executed notebook.
115+
If this flag is provided, the newly generated notebook will
116+
overwrite the input notebook.
117+
"""
118+
),
119+
).tag(config=True)
120+
output_base = Unicode(
121+
None,
122+
allow_none=True,
123+
help=dedent(
124+
"""
125+
Write executed notebook to this file base name.
126+
Supports pattern replacements ``'{notebook_name}'``,
127+
the name of the input notebook file without extension.
128+
Note that output is always relative to the parent directory of the
129+
input notebook.
130+
"""
131+
),
132+
).tag(config=True)
101133

102134
@default("log_level")
103135
def _log_level_default(self) -> int:
@@ -115,6 +147,15 @@ def initialize(self, argv: list[str] | None = None) -> None:
115147
if not self.notebooks:
116148
sys.exit(-1)
117149

150+
# If output, must have single notebook
151+
if len(self.notebooks) > 1 and self.output_base is not None:
152+
if "{notebook_name}" not in self.output_base:
153+
msg = (
154+
"If passing multiple notebooks with `--output=output` option, "
155+
"output string must contain {notebook_name}"
156+
)
157+
raise ValueError(msg)
158+
118159
# Loop and run them one by one
119160
for path in self.notebooks:
120161
self.run_notebook(path)
@@ -136,16 +177,27 @@ def run_notebook(self, notebook_path: str) -> None:
136177
# Log it
137178
self.log.info(f"Executing {notebook_path}")
138179

139-
name = notebook_path.replace(".ipynb", "")
180+
input_path = Path(notebook_path).with_suffix(".ipynb")
140181

141182
# Get its parent directory so we can add it to the $PATH
142-
path = pathlib.Path(notebook_path).parent.absolute()
183+
path = input_path.parent.absolute()
184+
185+
# Optional output of executed notebook
186+
if self.inplace:
187+
output_path = input_path
188+
elif self.output_base:
189+
output_path = input_path.parent.joinpath(
190+
self.output_base.format(notebook_name=input_path.with_suffix("").name)
191+
).with_suffix(".ipynb")
192+
else:
193+
output_path = None
143194

144-
# Set the input file paths
145-
input_path = f"{name}.ipynb"
195+
if output_path and not output_path.parent.is_dir():
196+
msg = f"Cannot write to directory={output_path.parent} that does not exist"
197+
raise ValueError(msg)
146198

147199
# Open up the notebook we're going to run
148-
with open(input_path) as f:
200+
with input_path.open() as f:
149201
nb = nbformat.read(f, as_version=4)
150202

151203
# Configure nbclient to run the notebook
@@ -162,5 +214,10 @@ def run_notebook(self, notebook_path: str) -> None:
162214
# Run it
163215
client.execute()
164216

217+
# Save it
218+
if output_path:
219+
self.log.info(f"Save executed results to {output_path}")
220+
nbformat.write(nb, output_path)
221+
165222

166223
main = NbClientApp.launch_instance

tests/test_cli.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
from pathlib import Path
2+
from subprocess import CalledProcessError, check_output
3+
from unittest.mock import call, mock_open, patch
4+
5+
import pytest
6+
7+
from nbclient.cli import NbClientApp
8+
9+
current_dir = Path(__file__).parent.absolute()
10+
11+
12+
@pytest.fixture()
13+
def jupyterapp():
14+
with patch("nbclient.cli.JupyterApp.initialize") as mocked:
15+
yield mocked
16+
17+
18+
@pytest.fixture()
19+
def client():
20+
with patch("nbclient.cli.NotebookClient", autospec=True) as mocked:
21+
yield mocked
22+
23+
24+
@pytest.fixture()
25+
def writer():
26+
with patch("nbformat.write", autospec=True) as mocked:
27+
yield mocked
28+
29+
30+
@pytest.fixture()
31+
def reader():
32+
with patch("nbformat.read", autospec=True, return_value="nb") as mocked:
33+
yield mocked
34+
35+
36+
@pytest.fixture()
37+
def path_open():
38+
opener = mock_open()
39+
40+
def mocked_open(self, *args, **kwargs):
41+
return opener(self, *args, **kwargs)
42+
43+
with patch("nbclient.cli.Path.open", mocked_open):
44+
yield opener
45+
46+
47+
@pytest.mark.parametrize(
48+
"input_names", [("Other Comms",), ("Other Comms.ipynb",), ("Other Comms", "HelloWorld.ipynb")]
49+
)
50+
@pytest.mark.parametrize("relative", [False, True])
51+
@pytest.mark.parametrize("inplace", [False, True])
52+
def test_mult(input_names, relative, inplace, jupyterapp, client, reader, writer, path_open):
53+
paths = [current_dir / "files" / name for name in input_names]
54+
if relative:
55+
paths = [p.relative_to(Path.cwd()) for p in paths]
56+
57+
c = NbClientApp(notebooks=[str(p) for p in paths], kernel_name="python3", inplace=inplace)
58+
c.initialize()
59+
60+
# add suffix if needed
61+
paths = [p.with_suffix(".ipynb") for p in paths]
62+
63+
assert path_open.mock_calls[::3] == [call(p) for p in paths]
64+
assert reader.call_count == len(paths)
65+
# assert reader.mock_calls == [call(p, as_version=4) for p in paths]
66+
67+
expected = []
68+
for p in paths:
69+
expected.extend(
70+
[
71+
call(
72+
"nb",
73+
timeout=c.timeout,
74+
startup_timeout=c.startup_timeout,
75+
skip_cells_with_tag=c.skip_cells_with_tag,
76+
allow_errors=c.allow_errors,
77+
kernel_name=c.kernel_name,
78+
resources={"metadata": {"path": p.parent.absolute()}},
79+
),
80+
call().execute(),
81+
]
82+
)
83+
84+
assert client.mock_calls == expected
85+
86+
if inplace:
87+
assert writer.mock_calls == [call("nb", p) for p in paths]
88+
else:
89+
writer.assert_not_called()
90+
91+
92+
@pytest.mark.parametrize(
93+
"input_names", [("Other Comms",), ("Other Comms.ipynb",), ("Other Comms", "HelloWorld.ipynb")]
94+
)
95+
@pytest.mark.parametrize("relative", [False, True])
96+
@pytest.mark.parametrize("output_base", ["thing", "thing.ipynb", "{notebook_name}-new.ipynb"])
97+
def test_output(input_names, relative, output_base, jupyterapp, client, reader, writer, path_open):
98+
paths = [current_dir / "files" / name for name in input_names]
99+
if relative:
100+
paths = [p.relative_to(Path.cwd()) for p in paths]
101+
102+
c = NbClientApp(
103+
notebooks=[str(p) for p in paths], kernel_name="python3", output_base=output_base
104+
)
105+
106+
if len(paths) != 1 and "{notebook_name}" not in output_base:
107+
with pytest.raises(ValueError) as e:
108+
c.initialize()
109+
assert "If passing multiple" in str(e.value)
110+
return
111+
112+
c.initialize()
113+
114+
# add suffix if needed
115+
paths = [p.with_suffix(".ipynb") for p in paths]
116+
117+
assert path_open.mock_calls[::3] == [call(p) for p in paths]
118+
assert reader.call_count == len(paths)
119+
120+
expected = []
121+
for p in paths:
122+
expected.extend(
123+
[
124+
call(
125+
"nb",
126+
timeout=c.timeout,
127+
startup_timeout=c.startup_timeout,
128+
skip_cells_with_tag=c.skip_cells_with_tag,
129+
allow_errors=c.allow_errors,
130+
kernel_name=c.kernel_name,
131+
resources={"metadata": {"path": p.parent.absolute()}},
132+
),
133+
call().execute(),
134+
]
135+
)
136+
137+
assert client.mock_calls == expected
138+
139+
assert writer.mock_calls == [
140+
call(
141+
"nb",
142+
(p.parent / output_base.format(notebook_name=p.with_suffix("").name)).with_suffix(
143+
".ipynb"
144+
),
145+
)
146+
for p in paths
147+
]
148+
149+
150+
def test_bad_output_dir(jupyterapp, client, reader, writer, path_open):
151+
input_names = ["Other Comms"]
152+
output_base = "thing/thing"
153+
154+
paths = [current_dir / "files" / name for name in input_names]
155+
156+
c = NbClientApp(
157+
notebooks=[str(p) for p in paths], kernel_name="python3", output_base=output_base
158+
)
159+
160+
with pytest.raises(ValueError) as e:
161+
c.initialize()
162+
163+
assert "Cannot write to directory" in str(e.value)
164+
165+
166+
# simple runner from command line
167+
def test_cli_simple():
168+
path = current_dir / "files" / "Other Comms"
169+
170+
with pytest.raises(CalledProcessError):
171+
check_output(["jupyter-execute", "--output", "thing/thing", str(path)]) # noqa: S603, S607
172+
173+
174+
def test_no_notebooks(jupyterapp):
175+
c = NbClientApp(notebooks=[], kernel_name="python3")
176+
177+
with pytest.raises(SystemExit):
178+
c.initialize()

0 commit comments

Comments
 (0)