Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d339b96
Add Jupyter notebook widget for WebGL viewer
claude Mar 23, 2026
a0ddbc6
Add example for Jupyter notebook WebGL viewer usage
claude Mar 23, 2026
5469bd0
Rename Jupyter example to plot_ prefix so sphinx-gallery renders it
claude Mar 23, 2026
01dc8d0
Use nbsphinx to render Jupyter notebook example in docs
claude Mar 23, 2026
56f6c6f
Add sphinx-gallery execution times file (auto-generated)
claude Mar 23, 2026
0e5e1a8
Pre-execute notebook to embed static WebGL viewer in docs
claude Mar 23, 2026
9d0bdc5
Move notebook to docs/notebooks/ and strip cell outputs
claude Mar 23, 2026
3ed20ea
Execute notebook at docs build time instead of committing outputs
claude Mar 23, 2026
92f9cb6
Move notebook under Example Gallery in docs index
claude Mar 23, 2026
46be3eb
Remove auto-generated sphinx-gallery execution times file
mvdoc Mar 23, 2026
e4c9cbc
Fix bugs and improve Jupyter WebGL widget
mvdoc Mar 23, 2026
f8c8756
Fix notebook to use make_static directory instead of srcdoc
mvdoc Mar 23, 2026
85c0528
Add nbsphinx, ipykernel, and pandoc to docs CI workflow
mvdoc Mar 23, 2026
c6c5868
Make nbsphinx optional in docs build
mvdoc Mar 23, 2026
76904cf
Address Copilot review comments on Jupyter widget PR
mvdoc Mar 24, 2026
1b306e4
Fix review issues and add comprehensive tests for Jupyter widget
mvdoc Apr 4, 2026
a2312ac
Merge remote-tracking branch 'origin/main' into claude/webgl-jupyter-…
mvdoc Apr 4, 2026
50ea87d
Add output_dir to display_static and improve Jupyter notebook docs
mvdoc Apr 5, 2026
320f359
Add headless 3D brain views notebook example
mvdoc Apr 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build_docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ jobs:
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y inkscape --no-install-recommends
sudo apt-get install -y inkscape pandoc --no-install-recommends
pip install --upgrade pip
pip install wheel setuptools numpy cython
# force using latest nibabel
pip install -U nibabel
pip install -q ipython Sphinx sphinx-gallery numpydoc # TODO: move to pyproject.toml
pip install -q ipython Sphinx sphinx-gallery numpydoc nbsphinx ipykernel # TODO: move to pyproject.toml
pip install -e . --no-build-isolation --group dev
python -c 'import cortex; print(cortex.__full_version__)'

Expand Down
265 changes: 265 additions & 0 deletions cortex/tests/test_jupyter_widget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
"""Tests for the Jupyter WebGL widget integration."""

import os
import re
import unittest
from unittest.mock import MagicMock, patch


class TestJupyterImports(unittest.TestCase):
"""Test that the jupyter module imports correctly."""

def test_import_module(self):
from cortex.webgl import jupyter

self.assertTrue(hasattr(jupyter, "display"))
self.assertTrue(hasattr(jupyter, "display_iframe"))
self.assertTrue(hasattr(jupyter, "display_static"))
self.assertTrue(hasattr(jupyter, "make_notebook_html"))
self.assertTrue(hasattr(jupyter, "StaticViewer"))

def test_import_from_webgl(self):
import cortex

self.assertTrue(hasattr(cortex.webgl, "jupyter"))


class TestFindFreePort(unittest.TestCase):
"""Test the _find_free_port helper."""

def test_returns_valid_port(self):
from cortex.webgl.jupyter import _find_free_port

port = _find_free_port()
self.assertIsInstance(port, int)
self.assertGreaterEqual(port, 1024)
self.assertLessEqual(port, 65535)

def test_returns_different_ports(self):
from cortex.webgl.jupyter import _find_free_port

ports = {_find_free_port() for _ in range(10)}
self.assertGreater(len(ports), 1)


class TestDisplay(unittest.TestCase):
"""Test the display() dispatch function."""

def test_invalid_method(self):
from cortex.webgl.jupyter import display

with self.assertRaises(ValueError):
display(None, method="invalid")

@patch("cortex.webgl.jupyter.display_iframe")
def test_dispatch_iframe(self, mock_iframe):
from cortex.webgl.jupyter import display

display("fake_data", method="iframe")
mock_iframe.assert_called_once()

@patch("cortex.webgl.jupyter.display_static")
def test_dispatch_static(self, mock_static):
from cortex.webgl.jupyter import display

display("fake_data", method="static")
mock_static.assert_called_once()

@patch("cortex.webgl.jupyter.display_iframe")
def test_forwards_kwargs(self, mock_iframe):
from cortex.webgl.jupyter import display

display("fake_data", method="iframe", width=800, height=400, port=5555)
mock_iframe.assert_called_once_with(
"fake_data", width=800, height=400, port=5555
)


class TestDisplayIframe(unittest.TestCase):
"""Test the IFrame-based display method."""

@patch("cortex.webgl.jupyter.ipydisplay")
@patch("cortex.webgl.view.show")
def test_calls_show_with_correct_params(self, mock_show, mock_display):
from cortex.webgl.jupyter import display_iframe

mock_show.return_value = MagicMock()
result = display_iframe("fake_data", port=9999)

mock_show.assert_called_once()
call_kwargs = mock_show.call_args.kwargs
self.assertFalse(call_kwargs.get("open_browser", True))
self.assertFalse(call_kwargs.get("autoclose", True))
self.assertEqual(call_kwargs["port"], 9999)
self.assertEqual(result, mock_show.return_value)

@patch("cortex.webgl.jupyter.ipydisplay")
@patch("cortex.webgl.view.show")
def test_iframe_url_contains_port(self, mock_show, mock_display):
from cortex.webgl.jupyter import display_iframe
from IPython.display import IFrame

mock_show.return_value = MagicMock()
display_iframe("fake_data", port=8888)

mock_display.assert_called_once()
iframe_arg = mock_display.call_args[0][0]
self.assertIsInstance(iframe_arg, IFrame)
self.assertIn("8888", iframe_arg.src)
self.assertIn("mixer.html", iframe_arg.src)

@patch("cortex.webgl.jupyter.ipydisplay")
@patch("cortex.webgl.view.show")
def test_auto_port_is_valid(self, mock_show, mock_display):
from cortex.webgl.jupyter import display_iframe

mock_show.return_value = MagicMock()
display_iframe("fake_data")

port = mock_show.call_args.kwargs["port"]
self.assertGreaterEqual(port, 1024)
self.assertLessEqual(port, 65535)


class TestDisplayStatic(unittest.TestCase):
"""Test the static HTML display method."""

def _fake_make_static(self, outpath, data, **kwargs):
os.makedirs(outpath, exist_ok=True)
with open(os.path.join(outpath, "index.html"), "w") as f:
f.write("<html><body>test</body></html>")

@patch("cortex.webgl.jupyter.ipydisplay")
@patch("cortex.webgl.view.make_static")
def test_returns_static_viewer(self, mock_make_static, mock_display):
from cortex.webgl.jupyter import display_static, StaticViewer

mock_make_static.side_effect = self._fake_make_static
result = display_static("fake_data", height=500)

mock_make_static.assert_called_once()
self.assertTrue(mock_make_static.call_args.kwargs.get("html_embed", False))
mock_display.assert_called_once()
self.assertIsInstance(result, StaticViewer)
self.assertTrue(hasattr(result, "close"))
self.assertTrue(hasattr(result, "iframe"))
result.close()

@patch("cortex.webgl.jupyter.ipydisplay")
@patch("cortex.webgl.view.make_static")
def test_close_cleans_tmpdir(self, mock_make_static, mock_display):
from cortex.webgl.jupyter import display_static

mock_make_static.side_effect = self._fake_make_static
result = display_static("fake_data")

self.assertTrue(os.path.isdir(result._tmpdir))
result.close()
self.assertFalse(os.path.isdir(result._tmpdir))

@patch("cortex.webgl.jupyter.ipydisplay")
@patch("cortex.webgl.view.make_static")
def test_make_static_failure_raises_runtime_error(
self, mock_make_static, mock_display
):
from cortex.webgl.jupyter import display_static

mock_make_static.side_effect = Exception("make_static failed")

with self.assertRaises(RuntimeError) as ctx:
display_static("fake_data")
self.assertIn("Failed to generate static viewer", str(ctx.exception))

@patch("cortex.webgl.jupyter.ipydisplay")
@patch("cortex.webgl.view.make_static")
def test_uses_os_assigned_port(self, mock_make_static, mock_display):
from cortex.webgl.jupyter import display_static

mock_make_static.side_effect = self._fake_make_static
result = display_static("fake_data")

iframe_arg = mock_display.call_args[0][0]
match = re.search(r":(\d+)/", iframe_arg.src)
self.assertIsNotNone(match)
port = int(match.group(1))
self.assertGreaterEqual(port, 1024)
self.assertLessEqual(port, 65535)
result.close()

@patch("cortex.webgl.jupyter.ipydisplay")
@patch("cortex.webgl.view.make_static")
def test_width_int_converted(self, mock_make_static, mock_display):
from cortex.webgl.jupyter import display_static

mock_make_static.side_effect = self._fake_make_static
result = display_static("fake_data", width=800)

mock_display.assert_called_once()
Comment thread
mvdoc marked this conversation as resolved.
result.close()


class TestMakeNotebookHtml(unittest.TestCase):
"""Test the raw HTML generation function."""

@patch("cortex.webgl.view.make_static")
def test_returns_html_string(self, mock_make_static):
from cortex.webgl.jupyter import make_notebook_html

def fake_make_static(outpath, data, **kwargs):
os.makedirs(outpath, exist_ok=True)
with open(os.path.join(outpath, "index.html"), "w") as f:
f.write("<!doctype html><html><body>viewer</body></html>")

mock_make_static.side_effect = fake_make_static

html = make_notebook_html("fake_data")
self.assertIn("<!doctype html>", html)
self.assertIn("viewer", html)

@patch("cortex.webgl.view.make_static")
def test_cleans_up_temp_dir(self, mock_make_static):
from cortex.webgl.jupyter import make_notebook_html

created_dirs = []

def fake_make_static(outpath, data, **kwargs):
created_dirs.append(os.path.dirname(outpath))
os.makedirs(outpath, exist_ok=True)
with open(os.path.join(outpath, "index.html"), "w") as f:
f.write("<html>test</html>")

mock_make_static.side_effect = fake_make_static

make_notebook_html("fake_data")

self.assertEqual(len(created_dirs), 1)
self.assertFalse(os.path.isdir(created_dirs[0]))


class TestNotebookTemplate(unittest.TestCase):
"""Test that the notebook HTML template exists and is valid."""

def test_template_exists(self):
import cortex.webgl

template_dir = os.path.dirname(cortex.webgl.__file__)
template_path = os.path.join(template_dir, "notebook.html")
self.assertTrue(
os.path.exists(template_path),
"notebook.html template not found at %s" % template_path,
)

def test_template_extends_base(self):
import cortex.webgl

template_dir = os.path.dirname(cortex.webgl.__file__)
template_path = os.path.join(template_dir, "notebook.html")
with open(template_path) as f:
content = f.read()
self.assertIn("extends template.html", content)
self.assertIn("block onload", content)
self.assertIn("block jsinit", content)


if __name__ == "__main__":
unittest.main()
11 changes: 9 additions & 2 deletions cortex/webgl/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Makes an interactive viewer for viewing data in a browser
"""
"""Makes an interactive viewer for viewing data in a browser"""

from typing import TYPE_CHECKING

from ..utils import DocLoader
Expand All @@ -13,3 +13,10 @@

show = DocLoader("show", ".view", "cortex.webgl", actual_func=_show)
make_static = DocLoader("make_static", ".view", "cortex.webgl", actual_func=_static)

try:
import IPython # noqa: F401
except ImportError:
pass
else:
from . import jupyter
Loading
Loading