Skip to content

Commit 1a38f0b

Browse files
committed
Save output as artifacts
1 parent 069b71b commit 1a38f0b

File tree

8 files changed

+133
-35
lines changed

8 files changed

+133
-35
lines changed

.github/workflows/main.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ jobs:
99
test:
1010
uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@v1
1111
with:
12+
artifact-path: output-*
1213
envs: |
1314
- linux: py310-test
1415
- linux: py311-test

conftest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
def pytest_addoption(parser):
2+
parser.addoption(
3+
"--output-path",
4+
action="store",
5+
default=None,
6+
help="Output directory to use for tests",
7+
)

jupyter_output_monitor/_monitor.py

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from playwright.sync_api import sync_playwright
1414

1515
from ._server import jupyter_server
16-
from ._utils import clear_notebook, isotime
16+
from ._utils import clear_notebook, isotime, max_uint8_difference
1717

1818
__all__ = ["monitor", "monitor_group"]
1919

@@ -50,8 +50,16 @@ def monitor_group():
5050
default=10,
5151
help="Time in s to wait after executing each cell",
5252
)
53+
@click.option(
54+
"--atol",
55+
default=0,
56+
help=(
57+
"If an output image for a cell exists, a new image will only be written "
58+
"out if the maximum uint8 difference between the two exceeds atol"
59+
),
60+
)
5361
@click.option("--headless", is_flag=True, help="Whether to run in headless mode")
54-
def monitor(notebook, url, output, wait_after_execute, headless):
62+
def monitor(notebook, url, output, wait_after_execute, atol, headless):
5563
if output is None:
5664
output = f"output-{iso_to_path(isotime())}"
5765

@@ -73,12 +81,12 @@ def monitor(notebook, url, output, wait_after_execute, headless):
7381
clear_notebook(notebook, os.path.join(notebook_dir, "notebook.ipynb"))
7482
with jupyter_server(notebook_dir) as server:
7583
url = server.base_url + "/lab/tree/notebook.ipynb"
76-
_monitor_output(url, output, wait_after_execute, headless)
84+
_monitor_output(url, output, wait_after_execute, atol, headless)
7785
else:
78-
_monitor_output(url, output, wait_after_execute, headless)
86+
_monitor_output(url, output, wait_after_execute, atol, headless)
7987

8088

81-
def _monitor_output(url, output, wait_after_execute, headless):
89+
def _monitor_output(url, output, wait_after_execute, atol, headless):
8290
# Index of the current last screenshot, by output index
8391
last_screenshot = {}
8492

@@ -129,13 +137,15 @@ def _monitor_output(url, output, wait_after_execute, headless):
129137
# Check if server is asking us to select a kernel
130138
dialogs = list(page.query_selector_all(".jp-Dialog-header"))
131139
for dialog in dialogs:
132-
if 'Select Kernel' in dialog.inner_text():
140+
if "Select Kernel" in dialog.inner_text():
133141
print("Server is asking to select a kernel, accepting default")
134142
accept = list(page.query_selector_all(".jp-mod-accept"))
135143
if len(accept) == 1:
136144
accept[0].click()
137145
else:
138-
print("Error: multiple accept buttons found, not sure which to click")
146+
print(
147+
"Error: multiple accept buttons found, not sure which to click",
148+
)
139149
sys.exit(1)
140150

141151
last_screenshot = {}
@@ -222,25 +232,43 @@ def _monitor_output(url, output, wait_after_execute, headless):
222232
):
223233
print(" -> change detected!")
224234

225-
timestamp = isotime()
226-
227-
screenshot_filename = os.path.join(
228-
output,
229-
f"output-{output_index:03d}-{iso_to_path(timestamp)}.png",
230-
)
231-
image = Image.open(BytesIO(screenshot_bytes))
232-
image.save(screenshot_filename)
233-
234-
log.write(
235-
f"{timestamp},output-changed,{output_index},{screenshot_filename}\n",
236-
)
237-
log.flush()
238-
239-
print(
240-
f"Saving screenshot of output {output_index} at {timestamp}",
241-
)
242-
243-
last_screenshot[output_index] = screenshot_bytes
235+
if output_index in last_screenshot:
236+
max_diff = max_uint8_difference(
237+
last_screenshot[output_index],
238+
screenshot_bytes,
239+
)
240+
else:
241+
max_diff = 256
242+
243+
if max_diff >= atol:
244+
print(
245+
f" -> maximum difference ({max_diff}) exceeds atol ({atol}), writing out image",
246+
)
247+
248+
timestamp = isotime()
249+
250+
screenshot_filename = os.path.join(
251+
output,
252+
f"output-{output_index:03d}-{iso_to_path(timestamp)}.png",
253+
)
254+
image = Image.open(BytesIO(screenshot_bytes))
255+
image.save(screenshot_filename)
256+
257+
log.write(
258+
f"{timestamp},output-changed,{output_index},{screenshot_filename}\n",
259+
)
260+
log.flush()
261+
262+
print(
263+
f"Saving screenshot of output {output_index} at {timestamp}",
264+
)
265+
266+
last_screenshot[output_index] = screenshot_bytes
267+
268+
else:
269+
print(
270+
f" -> maximum difference ({max_diff}) not does exceed atol ({atol}), skipping",
271+
)
244272

245273
print("Stopping monitoring output and moving on to next input cell")
246274

jupyter_output_monitor/_utils.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import datetime
2+
import io
23
import socket
34

5+
import numpy as np
46
from nbconvert import NotebookExporter
7+
from PIL import Image
58
from traitlets.config import Config
69

7-
__all__ = ["get_free_port", "clear_notebook", "isotime"]
10+
__all__ = ["get_free_port", "clear_notebook", "isotime", "max_uint8_difference"]
811

912

1013
def get_free_port():
@@ -31,3 +34,25 @@ def clear_notebook(input_notebook, output_notebook):
3134

3235
def isotime():
3336
return datetime.datetime.now().isoformat()
37+
38+
39+
def max_uint8_difference(image1_bytes, image2_bytes):
40+
# Load images from bytes
41+
image1 = Image.open(io.BytesIO(image1_bytes)).convert("RGB")
42+
image2 = Image.open(io.BytesIO(image2_bytes)).convert("RGB")
43+
44+
# Convert images to numpy arrays
45+
array1 = np.array(image1, dtype=np.uint8)
46+
array2 = np.array(image2, dtype=np.uint8)
47+
48+
# Ensure both images have the same dimensions
49+
if array1.shape != array2.shape:
50+
return 256
51+
52+
# Calculate the absolute difference
53+
diff = np.abs(array1.astype(np.int16) - array2.astype(np.int16))
54+
55+
# Find the maximum difference
56+
max_diff = np.max(diff)
57+
58+
return max_diff

jupyter_output_monitor/conftest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import pathlib
2+
import tempfile
3+
4+
import pytest
5+
6+
7+
@pytest.fixture()
8+
def output_path(request):
9+
path_option = request.config.getoption("--output-path")
10+
if path_option:
11+
yield pathlib.Path(path_option)
12+
else:
13+
# Create a temporary directory if no path is specified
14+
temp_dir = tempfile.TemporaryDirectory()
15+
yield pathlib.Path(temp_dir.name)
16+
temp_dir.cleanup()

jupyter_output_monitor/tests/test_monitor.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@
33
import sys
44
from pathlib import Path
55

6+
import pytest
7+
68
DATA = Path(__file__).parent / "data"
79

810

9-
def test_simple(tmp_path):
10-
output_path = tmp_path / "output"
11+
@pytest.mark.parametrize("threshold", [None, 2])
12+
def test_simple(output_path, threshold):
13+
if threshold:
14+
output_path = output_path / "simple_threshold"
15+
else:
16+
output_path = output_path / "simple"
17+
extra = [] if threshold is None else ["--atol", str(threshold)]
1118
subprocess.run(
1219
[
1320
sys.executable,
@@ -19,6 +26,7 @@ def test_simple(tmp_path):
1926
"--output",
2027
str(output_path),
2128
"--headless",
29+
*extra,
2230
],
2331
check=True,
2432
)
@@ -29,18 +37,30 @@ def test_simple(tmp_path):
2937
assert len(list(output_path.glob("input-*.png"))) == 5
3038

3139
# Output screenshots
32-
assert len(list(output_path.glob("output-*.png"))) == 4
40+
if threshold:
41+
assert len(list(output_path.glob("output-*.png"))) in (4, 5)
42+
else:
43+
assert len(list(output_path.glob("output-*.png"))) >= 4
3344

34-
# Specifically for cell with index 33
35-
assert len(list(output_path.glob("output-003-*.png"))) == 1
45+
# Specifically for cell with index 3
46+
if threshold:
47+
assert len(list(output_path.glob("output-003-*.png"))) == 1
48+
else:
49+
assert len(list(output_path.glob("output-003-*.png"))) >= 1
3650

3751
# Specifically for cell with index 33
38-
assert len(list(output_path.glob("output-033-*.png"))) == 3
52+
if threshold:
53+
assert len(list(output_path.glob("output-033-*.png"))) in (3, 4)
54+
else:
55+
assert len(list(output_path.glob("output-033-*.png"))) >= 3
3956

4057
# Check that event log exists and is parsable
4158
with open(output_path / "event_log.csv") as f:
4259
reader = csv.reader(f, delimiter=",")
43-
assert len(list(reader)) == 10
60+
if threshold:
61+
assert len(list(reader)) in (10, 11)
62+
else:
63+
assert len(list(reader)) >= 10
4464

4565
subprocess.run(
4666
[

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ lint.ignore = [
5050
"D103",
5151
"D104",
5252
"C901",
53+
"PLR0913",
5354
"PLR0915",
5455
"PLR2004",
5556
"DTZ",

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ extras =
1111
commands =
1212
pip freeze
1313
playwright install chromium
14-
pytest --pyargs jupyter_output_monitor {posargs}
14+
pytest --pyargs jupyter_output_monitor {posargs} --output-path {toxinidir}/output-{envname}

0 commit comments

Comments
 (0)