From d339b96021e0c1a7f6d4be83120907c61e597ba7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 04:28:56 +0000 Subject: [PATCH 01/15] Add Jupyter notebook widget for WebGL viewer Implements two display methods for embedding pycortex WebGL brain viewers in Jupyter notebooks: - display_iframe: Embeds the Tornado-served viewer in an IFrame for full interactivity (surface morphing, data switching, WebSocket) - display_static: Generates self-contained HTML served via a lightweight HTTP server, suitable for sharing https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd --- cortex/tests/test_jupyter_widget.py | 155 +++++++++++++++++++++ cortex/webgl/__init__.py | 10 +- cortex/webgl/jupyter.py | 206 ++++++++++++++++++++++++++++ cortex/webgl/notebook.html | 25 ++++ 4 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 cortex/tests/test_jupyter_widget.py create mode 100644 cortex/webgl/jupyter.py create mode 100644 cortex/webgl/notebook.html diff --git a/cortex/tests/test_jupyter_widget.py b/cortex/tests/test_jupyter_widget.py new file mode 100644 index 000000000..403b7158e --- /dev/null +++ b/cortex/tests/test_jupyter_widget.py @@ -0,0 +1,155 @@ +"""Tests for the Jupyter WebGL widget integration.""" +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +import numpy as np + + +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')) + + def test_import_from_webgl(self): + import cortex + self.assertTrue(hasattr(cortex.webgl, 'jupyter')) + + def test_display_invalid_method(self): + from cortex.webgl.jupyter import display + with self.assertRaises(ValueError): + display(None, method="invalid") + + +class TestDisplayIframe(unittest.TestCase): + """Test the IFrame-based display method.""" + + @patch('cortex.webgl.jupyter.ipydisplay') + @patch('cortex.webgl.view.show') + def test_iframe_calls_show(self, mock_show, mock_display): + """Test that display_iframe calls show() with correct params.""" + from cortex.webgl.jupyter import display_iframe + + mock_server = MagicMock() + mock_show.return_value = mock_server + + result = display_iframe("fake_data", port=9999) + + mock_show.assert_called_once() + call_kwargs = mock_show.call_args + self.assertFalse(call_kwargs.kwargs.get('open_browser', True)) + self.assertFalse(call_kwargs.kwargs.get('autoclose', True)) + self.assertEqual(result, mock_server) + + @patch('cortex.webgl.jupyter.ipydisplay') + @patch('cortex.webgl.view.show') + def test_iframe_displays_iframe(self, mock_show, mock_display): + """Test that an IFrame is displayed.""" + from cortex.webgl.jupyter import display_iframe + from IPython.display import IFrame + + mock_show.return_value = MagicMock() + display_iframe("fake_data", port=8888, height=400) + + mock_display.assert_called_once() + iframe_arg = mock_display.call_args[0][0] + self.assertIsInstance(iframe_arg, IFrame) + + +class TestDisplayStatic(unittest.TestCase): + """Test the static HTML display method.""" + + @patch('cortex.webgl.jupyter.ipydisplay') + @patch('cortex.webgl.view.make_static') + def test_static_creates_tempdir(self, mock_make_static, mock_display): + """Test that display_static creates a temp directory and HTML.""" + from cortex.webgl.jupyter import display_static + + # Mock make_static to create a fake index.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("test") + + mock_make_static.side_effect = fake_make_static + + result = display_static("fake_data", height=500) + + mock_make_static.assert_called_once() + call_kwargs = mock_make_static.call_args + self.assertTrue(call_kwargs.kwargs.get('html_embed', False)) + mock_display.assert_called_once() + # Result should be an IFrame + from IPython.display import IFrame + self.assertIsInstance(result, IFrame) + + @patch('cortex.webgl.jupyter.ipydisplay') + @patch('cortex.webgl.view.make_static') + def test_static_width_int(self, mock_make_static, mock_display): + """Test integer width is converted to px string.""" + from cortex.webgl.jupyter import display_static + + 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("test") + + mock_make_static.side_effect = fake_make_static + + result = display_static("fake_data", width=800) + mock_display.assert_called_once() + from IPython.display import IFrame + self.assertIsInstance(result, IFrame) + + +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): + """Test that make_notebook_html returns an HTML string.""" + 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("viewer") + + mock_make_static.side_effect = fake_make_static + + html = make_notebook_html("fake_data") + self.assertIn("", html) + self.assertIn("viewer", html) + + +class TestNotebookTemplate(unittest.TestCase): + """Test that the notebook HTML template exists and is valid.""" + + def test_template_exists(self): + """Test that notebook.html template exists.""" + 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): + """Test that notebook.html extends template.html.""" + 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() diff --git a/cortex/webgl/__init__.py b/cortex/webgl/__init__.py index b31971b5e..3542b25b0 100644 --- a/cortex/webgl/__init__.py +++ b/cortex/webgl/__init__.py @@ -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 @@ -13,3 +13,9 @@ show = DocLoader("show", ".view", "cortex.webgl", actual_func=_show) make_static = DocLoader("make_static", ".view", "cortex.webgl", actual_func=_static) + +try: + from . import jupyter +except ImportError: + # IPython not available + pass diff --git a/cortex/webgl/jupyter.py b/cortex/webgl/jupyter.py new file mode 100644 index 000000000..5689f484c --- /dev/null +++ b/cortex/webgl/jupyter.py @@ -0,0 +1,206 @@ +"""Jupyter notebook integration for pycortex WebGL viewer. + +Provides two approaches for displaying brain surfaces in Jupyter notebooks: + +1. **IFrame-based** (``display_iframe``): Starts a Tornado server and embeds + the viewer in an IFrame. Full interactivity with WebSocket support. + +2. **Static HTML** (``display_static``): Generates a self-contained HTML viewer + with all resources embedded. Works in static notebooks (nbviewer, GitHub). + +Usage +----- +>>> import cortex +>>> vol = cortex.Volume.random("S1", "fullhead") +>>> cortex.webgl.jupyter.display(vol) # auto-detects best approach +""" +import json +import os +import random +import tempfile +import warnings + +from IPython.display import HTML, IFrame +from IPython.display import display as ipydisplay + + +def display(data, method="iframe", width="100%", height=600, **kwargs): + """Display brain data in a Jupyter notebook using the WebGL viewer. + + Parameters + ---------- + data : Dataset, Volume, Vertex, or dict + Brain data to display. + method : str, optional + Display method: "iframe" for server-based (interactive, default), + "static" for self-contained HTML (works in nbviewer). + width : str or int, optional + Widget width. Default "100%". + height : int, optional + Widget height in pixels. Default 600. + **kwargs + Additional keyword arguments passed to ``show()`` or ``make_static()``. + + Returns + ------- + For "iframe": the server object (JSMixer or WebApp) + For "static": the IPython HTML display object + """ + if method == "iframe": + return display_iframe(data, width=width, height=height, **kwargs) + elif method == "static": + return display_static(data, width=width, height=height, **kwargs) + else: + raise ValueError("method must be 'iframe' or 'static', got %r" % method) + + +def display_iframe(data, width="100%", height=600, port=None, **kwargs): + """Display brain data via an embedded IFrame connected to a Tornado server. + + Starts the pycortex Tornado server and embeds it in an IFrame within the + notebook. Provides full interactivity including surface morphing, data + switching, and WebSocket-based Python control. + + Parameters + ---------- + data : Dataset, Volume, Vertex, or dict + Brain data to display. + width : str or int, optional + IFrame width. Default "100%". + height : int, optional + IFrame height in pixels. Default 600. + port : int or None, optional + Port for the Tornado server. If None, a random port is chosen. + **kwargs + Additional keyword arguments passed to ``cortex.webgl.show()``. + + Returns + ------- + server : WebApp + The Tornado server object. Can be used to get a JSMixer client for + programmatic control. + """ + from . import view, serve + + if port is None: + port = random.randint(1024, 65536) + + # Start the server without opening a browser + kwargs['open_browser'] = False + kwargs['autoclose'] = False + server = view.show(data, port=port, **kwargs) + + url = "http://%s:%d/mixer.html" % (serve.hostname, port) + + # Format width for IFrame + if isinstance(width, int): + width = "%dpx" % width + + ipydisplay(IFrame(src=url, width=width, height=height)) + + return server + + +def display_static(data, width="100%", height=600, **kwargs): + """Display brain data as a self-contained HTML viewer inline. + + Generates a complete static viewer with all JS/CSS/data embedded, + then displays it in the notebook. This works in static notebook + renderers like nbviewer and GitHub. + + Note: The embedded HTML is large (~4-5MB) because all JavaScript + libraries and CSS are inlined. + + Parameters + ---------- + data : Dataset, Volume, Vertex, or dict + Brain data to display. + width : str or int, optional + Viewer width. Default "100%". + height : int, optional + Viewer height in pixels. Default 600. + **kwargs + Additional keyword arguments passed to ``cortex.webgl.make_static()``. + + Returns + ------- + iframe : IPython.display.IFrame + The IFrame display object. + """ + from . import view + + # Create a temporary directory for the static viewer + tmpdir = tempfile.mkdtemp(prefix="pycortex_jupyter_") + outpath = os.path.join(tmpdir, "viewer") + + # Generate the static viewer + view.make_static(outpath, data, html_embed=True, **kwargs) + + # Read the generated HTML + index_html = os.path.join(outpath, "index.html") + with open(index_html, "r") as f: + html_content = f.read() + + # Format width + if isinstance(width, int): + width_str = "%dpx" % width + else: + width_str = width + + # Serve via a minimal local HTTP server to avoid srcdoc size limits + # and cross-origin issues with data URIs in the embedded HTML + import http.server + import threading + + # Find a free port + port = random.randint(10000, 65000) + + class QuietHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **handler_kwargs): + super().__init__(*args, directory=outpath, **handler_kwargs) + + def log_message(self, format, *args): + pass # Suppress log output in notebook + + httpd = http.server.HTTPServer(("127.0.0.1", port), QuietHandler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + + iframe = IFrame(src="http://127.0.0.1:%d/index.html" % port, + width=width_str, height=height) + ipydisplay(iframe) + return iframe + + +def make_notebook_html(data, template="static.html", types=("inflated",), **kwargs): + """Generate a self-contained HTML string for the WebGL viewer. + + This is a lower-level function that returns the raw HTML string rather + than displaying it. Useful for saving or embedding in custom contexts. + + Parameters + ---------- + data : Dataset, Volume, Vertex, or dict + Brain data to display. + template : str, optional + HTML template name. Default "static.html". + types : tuple, optional + Surface types to include. Default ("inflated",). + **kwargs + Additional keyword arguments passed to ``make_static()``. + + Returns + ------- + html : str + The self-contained HTML string. + """ + tmpdir = tempfile.mkdtemp(prefix="pycortex_nb_") + outpath = os.path.join(tmpdir, "viewer") + + from . import view + view.make_static(outpath, data, template=template, types=types, + html_embed=True, **kwargs) + + index_html = os.path.join(outpath, "index.html") + with open(index_html, "r") as f: + return f.read() diff --git a/cortex/webgl/notebook.html b/cortex/webgl/notebook.html new file mode 100644 index 000000000..4f43b32cf --- /dev/null +++ b/cortex/webgl/notebook.html @@ -0,0 +1,25 @@ +{% autoescape None %} +{% extends template.html %} +{% block jsinit %} + var viewer, subjects, datasets, figure, sock, viewopts; +{% end %} +{% block onload %} + viewopts = {{viewopts}}; + subjects = {{subjects}}; + for (var name in subjects) { + subjects[name] = new mriview.Surface(subjects[name]); + } + + figure = new jsplot.W2Figure(); + viewer = figure.add(mriview.Viewer, "main", true); + + dataviews = dataset.fromJSON({{data}}); + viewer.addData(dataviews); + + // Notebook-specific: auto-resize to fill iframe + window.addEventListener('resize', function() { + if (viewer && viewer.canvas) { + viewer.resize(); + } + }); +{% end %} From a0ddbc6903464683640d485497ff1ddc189d8788 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 13:17:04 +0000 Subject: [PATCH 02/15] Add example for Jupyter notebook WebGL viewer usage Covers IFrame mode, static mode, programmatic control via JSMixer, viewer customization options, and raw HTML generation. https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd --- examples/webgl/jupyter_notebook.py | 95 ++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 examples/webgl/jupyter_notebook.py diff --git a/examples/webgl/jupyter_notebook.py b/examples/webgl/jupyter_notebook.py new file mode 100644 index 000000000..ecdb19786 --- /dev/null +++ b/examples/webgl/jupyter_notebook.py @@ -0,0 +1,95 @@ +""" +================================= +Display WebGL Viewer in a Jupyter Notebook +================================= + +Pycortex can embed the interactive WebGL brain viewer directly in a Jupyter +notebook cell. There are two methods: + +1. **IFrame mode** (default): Starts a background Tornado server and embeds + it in an IFrame. Provides full interactivity including surface morphing, + data switching, and programmatic control from Python via WebSocket. + +2. **Static mode**: Generates a self-contained HTML viewer served by a + lightweight local HTTP server. Useful when you want to avoid the Tornado + dependency or need a simpler setup. + +Both methods are **non-blocking** — subsequent notebook cells execute +immediately. +""" +# sphinx_gallery_thumbnail_path = '' + +import cortex +import numpy as np + +np.random.seed(1234) + +############################################################################### +# Create some example data +# ------------------------ +volume = cortex.Volume.random(subject='S1', xfmname='fullhead') + +############################################################################### +# Method 1: IFrame mode (recommended for interactive exploration) +# --------------------------------------------------------------- +# This starts a Tornado server in a background thread and embeds the viewer +# in an IFrame. The cell returns immediately — you can keep running code. + +server = cortex.webgl.jupyter.display(volume) + +############################################################################### +# The server runs in the background, so you can execute more cells right away. +# For example, create a second dataset and display it in another viewer: + +volume2 = cortex.Volume.random(subject='S1', xfmname='fullhead') +server2 = cortex.webgl.jupyter.display(volume2) + +############################################################################### +# Programmatic control via JSMixer +# -------------------------------- +# After the viewer has loaded in the IFrame, you can get a control handle. +# NOTE: ``get_client()`` blocks until the browser connects via WebSocket, +# so call it in a separate cell from ``display()``. + +client = server.get_client() + +# Rotate the view +client._set_view(azimuth=45, altitude=30) + +# Switch to inflated surface (mix=1.0 is fully inflated) +client._set_view(mix=1.0) + +# Capture a screenshot +client.getImage("notebook_screenshot.png") + +############################################################################### +# Method 2: Static mode +# --------------------- +# Generates a self-contained HTML viewer. The ``make_static()`` call takes +# a few seconds to generate CTM meshes and embed resources, then serves +# the result via a local HTTP server. + +cortex.webgl.jupyter.display(volume, method="static") + +############################################################################### +# Customizing the viewer +# ---------------------- +# Both methods accept the same keyword arguments as ``cortex.webgl.show()`` +# and ``cortex.webgl.make_static()``. + +cortex.webgl.jupyter.display( + volume, + types=("inflated",), # Surface types to include + overlays_visible=("sulci",), # Show sulci overlay by default + height=400, # Shorter viewer + title="My Experiment", +) + +############################################################################### +# Lower-level: get raw HTML +# ------------------------- +# ``make_notebook_html()`` returns the self-contained HTML as a string, +# useful for saving to disk or embedding in custom web pages. + +html = cortex.webgl.jupyter.make_notebook_html(volume) +print("Generated HTML: %d bytes" % len(html)) From 5469bd06a491bcdccb0e94299f839f1de549b44a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 13:24:34 +0000 Subject: [PATCH 03/15] Rename Jupyter example to plot_ prefix so sphinx-gallery renders it Sphinx-gallery only processes files matching the /plot_ pattern. Renamed jupyter_notebook.py -> plot_jupyter_notebook.py and added sphinx_gallery_dummy_images directive since this example cannot execute during doc builds (requires Jupyter + browser). https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd --- .../{jupyter_notebook.py => plot_jupyter_notebook.py} | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) rename examples/webgl/{jupyter_notebook.py => plot_jupyter_notebook.py} (91%) diff --git a/examples/webgl/jupyter_notebook.py b/examples/webgl/plot_jupyter_notebook.py similarity index 91% rename from examples/webgl/jupyter_notebook.py rename to examples/webgl/plot_jupyter_notebook.py index ecdb19786..e3c004ae2 100644 --- a/examples/webgl/jupyter_notebook.py +++ b/examples/webgl/plot_jupyter_notebook.py @@ -1,7 +1,7 @@ """ -================================= +============================================= Display WebGL Viewer in a Jupyter Notebook -================================= +============================================= Pycortex can embed the interactive WebGL brain viewer directly in a Jupyter notebook cell. There are two methods: @@ -16,8 +16,15 @@ Both methods are **non-blocking** — subsequent notebook cells execute immediately. + +.. note:: + + This example cannot be executed during the documentation build because it + requires a running Jupyter kernel and a browser. The code below is shown + for reference only. """ # sphinx_gallery_thumbnail_path = '' +# sphinx_gallery_dummy_images = 1 import cortex import numpy as np From 01dc8d06b78f2ef6a5ac06894d5b2d81db807270 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 13:46:22 +0000 Subject: [PATCH 04/15] Use nbsphinx to render Jupyter notebook example in docs - Add nbsphinx extension to Sphinx conf with execute='never' - Exclude auto_examples/*.ipynb to avoid conflict with sphinx-gallery - Replace plot_jupyter_notebook.py with jupyter_notebook.ipynb - Symlink notebook from examples/webgl/ into docs/notebooks/ - Add "Jupyter Notebooks" section to docs index toctree https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd --- docs/conf.py | 9 +- docs/index.rst | 9 +- docs/notebooks/jupyter_notebook.ipynb | 1 + examples/webgl/jupyter_notebook.ipynb | 189 ++++++++++++++++++++++++ examples/webgl/plot_jupyter_notebook.py | 102 ------------- 5 files changed, 205 insertions(+), 105 deletions(-) create mode 120000 docs/notebooks/jupyter_notebook.ipynb create mode 100644 examples/webgl/jupyter_notebook.ipynb delete mode 100644 examples/webgl/plot_jupyter_notebook.py diff --git a/docs/conf.py b/docs/conf.py index 38020b289..914295e64 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,11 +33,16 @@ 'sphinx.ext.autosummary', 'numpydoc', 'sphinx.ext.githubpages', - 'sphinx_gallery.gen_gallery'] + 'sphinx_gallery.gen_gallery', + 'nbsphinx'] autosummary_generate = True numpydoc_show_class_members=False +# nbsphinx – render Jupyter notebooks in docs without re-executing them +nbsphinx_execute = 'never' +nbsphinx_custom_formats = {} + # Sphinx-gallery sphinx_gallery_conf = { # path to your examples scripts @@ -84,7 +89,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ['_build', 'auto_examples/**/*.ipynb'] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None diff --git a/docs/index.rst b/docs/index.rst index 1c4d421ad..b3891208e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,9 +30,16 @@ Example Gallery --------------- .. toctree:: :maxdepth: 3 - + auto_examples/index +Jupyter Notebooks +----------------- +.. toctree:: + :maxdepth: 2 + + notebooks/jupyter_notebook + API Reference ------------- .. toctree:: diff --git a/docs/notebooks/jupyter_notebook.ipynb b/docs/notebooks/jupyter_notebook.ipynb new file mode 120000 index 000000000..41480435e --- /dev/null +++ b/docs/notebooks/jupyter_notebook.ipynb @@ -0,0 +1 @@ +../../examples/webgl/jupyter_notebook.ipynb \ No newline at end of file diff --git a/examples/webgl/jupyter_notebook.ipynb b/examples/webgl/jupyter_notebook.ipynb new file mode 100644 index 000000000..42f7aab4c --- /dev/null +++ b/examples/webgl/jupyter_notebook.ipynb @@ -0,0 +1,189 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Display WebGL Viewer in a Jupyter Notebook\n", + "\n", + "Pycortex can embed the interactive WebGL brain viewer directly in a Jupyter\n", + "notebook cell. There are two methods:\n", + "\n", + "1. **IFrame mode** (default): Starts a background Tornado server and embeds\n", + " it in an IFrame. Provides full interactivity including surface morphing,\n", + " data switching, and programmatic control from Python via WebSocket.\n", + "\n", + "2. **Static mode**: Generates a self-contained HTML viewer served by a\n", + " lightweight local HTTP server. Useful when you want to avoid the Tornado\n", + " dependency or need a simpler setup.\n", + "\n", + "Both methods are **non-blocking** \u2014 subsequent notebook cells execute immediately." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create some example data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import cortex\n", + "import numpy as np\n", + "\n", + "np.random.seed(1234)\n", + "volume = cortex.Volume.random(subject='S1', xfmname='fullhead')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Method 1: IFrame mode (recommended for interactive exploration)\n", + "\n", + "This starts a Tornado server in a background thread and embeds the viewer\n", + "in an IFrame. The cell returns immediately \u2014 you can keep running code." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "server = cortex.webgl.jupyter.display(volume)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The server runs in the background, so you can execute more cells right away.\n", + "For example, create a second dataset and display it in another viewer:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "volume2 = cortex.Volume.random(subject='S1', xfmname='fullhead')\n", + "server2 = cortex.webgl.jupyter.display(volume2)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Programmatic control via JSMixer\n", + "\n", + "After the viewer has loaded in the IFrame, you can get a control handle.\n", + "\n", + "> **Note:** `get_client()` blocks until the browser connects via WebSocket,\n", + "> so call it in a separate cell from `display()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "client = server.get_client()\n", + "\n", + "# Rotate the view\n", + "client._set_view(azimuth=45, altitude=30)\n", + "\n", + "# Switch to inflated surface (mix=1.0 is fully inflated)\n", + "client._set_view(mix=1.0)\n", + "\n", + "# Capture a screenshot\n", + "client.getImage(\"notebook_screenshot.png\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Method 2: Static mode\n", + "\n", + "Generates a self-contained HTML viewer. The `make_static()` call takes\n", + "a few seconds to generate CTM meshes and embed resources, then serves\n", + "the result via a local HTTP server." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "cortex.webgl.jupyter.display(volume, method=\"static\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Customizing the viewer\n", + "\n", + "Both methods accept the same keyword arguments as `cortex.webgl.show()`\n", + "and `cortex.webgl.make_static()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "cortex.webgl.jupyter.display(\n", + " volume,\n", + " types=(\"inflated\",), # Surface types to include\n", + " overlays_visible=(\"sulci\",), # Show sulci overlay by default\n", + " height=400, # Shorter viewer\n", + " title=\"My Experiment\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Lower-level: get raw HTML\n", + "\n", + "`make_notebook_html()` returns the self-contained HTML as a string,\n", + "useful for saving to disk or embedding in custom web pages." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "html = cortex.webgl.jupyter.make_notebook_html(volume)\n", + "print(\"Generated HTML: %d bytes\" % len(html))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.9.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/webgl/plot_jupyter_notebook.py b/examples/webgl/plot_jupyter_notebook.py deleted file mode 100644 index e3c004ae2..000000000 --- a/examples/webgl/plot_jupyter_notebook.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -============================================= -Display WebGL Viewer in a Jupyter Notebook -============================================= - -Pycortex can embed the interactive WebGL brain viewer directly in a Jupyter -notebook cell. There are two methods: - -1. **IFrame mode** (default): Starts a background Tornado server and embeds - it in an IFrame. Provides full interactivity including surface morphing, - data switching, and programmatic control from Python via WebSocket. - -2. **Static mode**: Generates a self-contained HTML viewer served by a - lightweight local HTTP server. Useful when you want to avoid the Tornado - dependency or need a simpler setup. - -Both methods are **non-blocking** — subsequent notebook cells execute -immediately. - -.. note:: - - This example cannot be executed during the documentation build because it - requires a running Jupyter kernel and a browser. The code below is shown - for reference only. -""" -# sphinx_gallery_thumbnail_path = '' -# sphinx_gallery_dummy_images = 1 - -import cortex -import numpy as np - -np.random.seed(1234) - -############################################################################### -# Create some example data -# ------------------------ -volume = cortex.Volume.random(subject='S1', xfmname='fullhead') - -############################################################################### -# Method 1: IFrame mode (recommended for interactive exploration) -# --------------------------------------------------------------- -# This starts a Tornado server in a background thread and embeds the viewer -# in an IFrame. The cell returns immediately — you can keep running code. - -server = cortex.webgl.jupyter.display(volume) - -############################################################################### -# The server runs in the background, so you can execute more cells right away. -# For example, create a second dataset and display it in another viewer: - -volume2 = cortex.Volume.random(subject='S1', xfmname='fullhead') -server2 = cortex.webgl.jupyter.display(volume2) - -############################################################################### -# Programmatic control via JSMixer -# -------------------------------- -# After the viewer has loaded in the IFrame, you can get a control handle. -# NOTE: ``get_client()`` blocks until the browser connects via WebSocket, -# so call it in a separate cell from ``display()``. - -client = server.get_client() - -# Rotate the view -client._set_view(azimuth=45, altitude=30) - -# Switch to inflated surface (mix=1.0 is fully inflated) -client._set_view(mix=1.0) - -# Capture a screenshot -client.getImage("notebook_screenshot.png") - -############################################################################### -# Method 2: Static mode -# --------------------- -# Generates a self-contained HTML viewer. The ``make_static()`` call takes -# a few seconds to generate CTM meshes and embed resources, then serves -# the result via a local HTTP server. - -cortex.webgl.jupyter.display(volume, method="static") - -############################################################################### -# Customizing the viewer -# ---------------------- -# Both methods accept the same keyword arguments as ``cortex.webgl.show()`` -# and ``cortex.webgl.make_static()``. - -cortex.webgl.jupyter.display( - volume, - types=("inflated",), # Surface types to include - overlays_visible=("sulci",), # Show sulci overlay by default - height=400, # Shorter viewer - title="My Experiment", -) - -############################################################################### -# Lower-level: get raw HTML -# ------------------------- -# ``make_notebook_html()`` returns the self-contained HTML as a string, -# useful for saving to disk or embedding in custom web pages. - -html = cortex.webgl.jupyter.make_notebook_html(volume) -print("Generated HTML: %d bytes" % len(html)) From 56f6c6f1d31ad796d2988ce9623da7ce3d2b9b9a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 13:46:50 +0000 Subject: [PATCH 05/15] Add sphinx-gallery execution times file (auto-generated) https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd --- docs/sg_execution_times.rst | 166 ++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/sg_execution_times.rst diff --git a/docs/sg_execution_times.rst b/docs/sg_execution_times.rst new file mode 100644 index 000000000..342c71ae5 --- /dev/null +++ b/docs/sg_execution_times.rst @@ -0,0 +1,166 @@ + +:orphan: + +.. _sphx_glr_sg_execution_times: + + +Computation times +================= +**01:47.090** total execution time for 44 files **from all galleries**: + +.. container:: + + .. raw:: html + + + + + + + + .. list-table:: + :header-rows: 1 + :class: table table-striped sg-datatable + + * - Example + - Time + - Mem (MB) + * - :ref:`sphx_glr_auto_examples_utils_plot_roi_voxel_index_volume.py` (``../examples/utils/plot_roi_voxel_index_volume.py``) + - 00:32.321 + - 0.0 + * - :ref:`sphx_glr_auto_examples_surface_analyses_plot_interpolate_data.py` (``../examples/surface_analyses/plot_interpolate_data.py``) + - 00:20.223 + - 0.0 + * - :ref:`sphx_glr_auto_examples_surface_analyses_plot_geodesic_distance.py` (``../examples/surface_analyses/plot_geodesic_distance.py``) + - 00:11.786 + - 0.0 + * - :ref:`sphx_glr_auto_examples_surface_analyses_plot_geodesic_path.py` (``../examples/surface_analyses/plot_geodesic_path.py``) + - 00:08.047 + - 0.0 + * - :ref:`sphx_glr_auto_examples_surface_analyses_plot_flatmap_distortion.py` (``../examples/surface_analyses/plot_flatmap_distortion.py``) + - 00:05.612 + - 0.0 + * - :ref:`sphx_glr_auto_examples_utils_plot_roi_voxel_mask.py` (``../examples/utils/plot_roi_voxel_mask.py``) + - 00:04.531 + - 0.0 + * - :ref:`sphx_glr_auto_examples_utils_plot_voxel_distance_from_surface.py` (``../examples/utils/plot_voxel_distance_from_surface.py``) + - 00:03.540 + - 0.0 + * - :ref:`sphx_glr_auto_examples_utils_plot_get_roi_vertices.py` (``../examples/utils/plot_get_roi_vertices.py``) + - 00:03.393 + - 0.0 + * - :ref:`sphx_glr_auto_examples_datasets_plot_volume_to_vertex.py` (``../examples/datasets/plot_volume_to_vertex.py``) + - 00:02.325 + - 0.0 + * - :ref:`sphx_glr_auto_examples_quickflat_plot_advanced_compositing.py` (``../examples/quickflat/plot_advanced_compositing.py``) + - 00:01.476 + - 0.0 + * - :ref:`sphx_glr_auto_examples_quickflat_plot_dropout.py` (``../examples/quickflat/plot_dropout.py``) + - 00:01.456 + - 0.0 + * - :ref:`sphx_glr_auto_examples_quickflat_plot_cutouts.py` (``../examples/quickflat/plot_cutouts.py``) + - 00:00.945 + - 0.0 + * - :ref:`sphx_glr_auto_examples_quickflat_plot_sulci.py` (``../examples/quickflat/plot_sulci.py``) + - 00:00.894 + - 0.0 + * - :ref:`sphx_glr_auto_examples_quickflat_plot_thickness_nanmean.py` (``../examples/quickflat/plot_thickness_nanmean.py``) + - 00:00.889 + - 0.0 + * - :ref:`sphx_glr_auto_examples_quickflat_plot_connected_vertices.py` (``../examples/quickflat/plot_connected_vertices.py``) + - 00:00.833 + - 0.0 + * - :ref:`sphx_glr_auto_examples_datasets_plot_vertex.py` (``../examples/datasets/plot_vertex.py``) + - 00:00.742 + - 0.0 + * - :ref:`sphx_glr_auto_examples_datasets_plot_dataset_arithmetic.py` (``../examples/datasets/plot_dataset_arithmetic.py``) + - 00:00.715 + - 0.0 + * - :ref:`sphx_glr_auto_examples_quickflat_plot_make_svg.py` (``../examples/quickflat/plot_make_svg.py``) + - 00:00.630 + - 0.0 + * - :ref:`sphx_glr_auto_examples_quickflat_plot_make_png.py` (``../examples/quickflat/plot_make_png.py``) + - 00:00.623 + - 0.0 + * - :ref:`sphx_glr_auto_examples_quickflat_plot_zoom_to_roi.py` (``../examples/quickflat/plot_zoom_to_roi.py``) + - 00:00.598 + - 0.0 + * - :ref:`sphx_glr_auto_examples_quickflat_plot_rois.py` (``../examples/quickflat/plot_rois.py``) + - 00:00.581 + - 0.0 + * - :ref:`sphx_glr_auto_examples_quickflat_plot_make_gif.py` (``../examples/quickflat/plot_make_gif.py``) + - 00:00.570 + - 0.0 + * - :ref:`sphx_glr_auto_examples_datasets_plot_volume.py` (``../examples/datasets/plot_volume.py``) + - 00:00.546 + - 0.0 + * - :ref:`sphx_glr_auto_examples_datasets_plot_volume2D.py` (``../examples/datasets/plot_volume2D.py``) + - 00:00.538 + - 0.0 + * - :ref:`sphx_glr_auto_examples_surface_analyses_plot_subsurfaces.py` (``../examples/surface_analyses/plot_subsurfaces.py``) + - 00:00.528 + - 0.0 + * - :ref:`sphx_glr_auto_examples_quickflat_plot_make_figure.py` (``../examples/quickflat/plot_make_figure.py``) + - 00:00.527 + - 0.0 + * - :ref:`sphx_glr_auto_examples_datasets_plot_volumeRGB.py` (``../examples/datasets/plot_volumeRGB.py``) + - 00:00.509 + - 0.0 + * - :ref:`sphx_glr_auto_examples_datasets_plot_vertexRGB.py` (``../examples/datasets/plot_vertexRGB.py``) + - 00:00.501 + - 0.0 + * - :ref:`sphx_glr_auto_examples_datasets_plot_vertex2D.py` (``../examples/datasets/plot_vertex2D.py``) + - 00:00.497 + - 0.0 + * - :ref:`sphx_glr_auto_examples_surface_analyses_plot_tissots_indicatrix.py` (``../examples/surface_analyses/plot_tissots_indicatrix.py``) + - 00:00.446 + - 0.0 + * - :ref:`sphx_glr_auto_examples_utils_plot_mosaic.py` (``../examples/utils/plot_mosaic.py``) + - 00:00.236 + - 0.0 + * - :ref:`sphx_glr_auto_examples_quickstart_plot_retinotopy_flatmap.py` (``../examples/quickstart/plot_retinotopy_flatmap.py``) + - 00:00.032 + - 0.0 + * - :ref:`sphx_glr_auto_examples_fsaverage_upsample_to_fsaverage.py` (``../examples/fsaverage/upsample_to_fsaverage.py``) + - 00:00.000 + - 0.0 + * - :ref:`sphx_glr_auto_examples_import_surface_import_fmriprep.py` (``../examples/import_surface/import_fmriprep.py``) + - 00:00.000 + - 0.0 + * - :ref:`sphx_glr_auto_examples_quickstart_retinotopy_webgl.py` (``../examples/quickstart/retinotopy_webgl.py``) + - 00:00.000 + - 0.0 + * - :ref:`sphx_glr_auto_examples_quickstart_show_config.py` (``../examples/quickstart/show_config.py``) + - 00:00.000 + - 0.0 + * - :ref:`sphx_glr_auto_examples_utils_mni_to_subject.py` (``../examples/utils/mni_to_subject.py``) + - 00:00.000 + - 0.0 + * - :ref:`sphx_glr_auto_examples_utils_multi_panels_plots.py` (``../examples/utils/multi_panels_plots.py``) + - 00:00.000 + - 0.0 + * - :ref:`sphx_glr_auto_examples_utils_subject_to_mni.py` (``../examples/utils/subject_to_mni.py``) + - 00:00.000 + - 0.0 + * - :ref:`sphx_glr_auto_examples_webgl_dynamic_with_custom_template.py` (``../examples/webgl/dynamic_with_custom_template.py``) + - 00:00.000 + - 0.0 + * - :ref:`sphx_glr_auto_examples_webgl_multiple_datasets.py` (``../examples/webgl/multiple_datasets.py``) + - 00:00.000 + - 0.0 + * - :ref:`sphx_glr_auto_examples_webgl_single_dataset.py` (``../examples/webgl/single_dataset.py``) + - 00:00.000 + - 0.0 + * - :ref:`sphx_glr_auto_examples_webgl_static.py` (``../examples/webgl/static.py``) + - 00:00.000 + - 0.0 + * - :ref:`sphx_glr_auto_examples_webgl_static_with_custom_template.py` (``../examples/webgl/static_with_custom_template.py``) + - 00:00.000 + - 0.0 From 0e5e1a8a0cced5e9db004e518941c482df324c4e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 13:51:58 +0000 Subject: [PATCH 06/15] Pre-execute notebook to embed static WebGL viewer in docs Restructured the notebook so the static viewer uses make_notebook_html() + IPython.display.HTML with an srcdoc IFrame, which persists the full 3D viewer in the cell output. The IFrame mode and other server-dependent examples are shown as markdown code blocks since they require a live Jupyter session. Executed via nbconvert --execute --inplace so nbsphinx renders the static viewer output directly in the built docs. https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd --- examples/webgl/jupyter_notebook.ipynb | 74230 +++++++++++++++++++++++- 1 file changed, 74142 insertions(+), 88 deletions(-) diff --git a/examples/webgl/jupyter_notebook.ipynb b/examples/webgl/jupyter_notebook.ipynb index 42f7aab4c..6177bcf38 100644 --- a/examples/webgl/jupyter_notebook.ipynb +++ b/examples/webgl/jupyter_notebook.ipynb @@ -13,11 +13,10 @@ " it in an IFrame. Provides full interactivity including surface morphing,\n", " data switching, and programmatic control from Python via WebSocket.\n", "\n", - "2. **Static mode**: Generates a self-contained HTML viewer served by a\n", - " lightweight local HTTP server. Useful when you want to avoid the Tornado\n", - " dependency or need a simpler setup.\n", + "2. **Static mode**: Generates a self-contained HTML viewer that can be\n", + " embedded inline. Works in static notebook renderers like nbviewer.\n", "\n", - "Both methods are **non-blocking** \u2014 subsequent notebook cells execute immediately." + "Both methods are **non-blocking** — subsequent notebook cells execute immediately." ] }, { @@ -29,8 +28,15 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, + "execution_count": 1, + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-23T13:48:57.891694Z", + "iopub.status.busy": "2026-03-23T13:48:57.891451Z", + "iopub.status.idle": "2026-03-23T13:48:58.693304Z", + "shell.execute_reply": "2026-03-23T13:48:58.691981Z" + } + }, "outputs": [], "source": [ "import cortex\n", @@ -44,57 +50,74123 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Method 1: IFrame mode (recommended for interactive exploration)\n", + "## Static viewer\n", "\n", - "This starts a Tornado server in a background thread and embeds the viewer\n", - "in an IFrame. The cell returns immediately \u2014 you can keep running code." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "server = cortex.webgl.jupyter.display(volume)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The server runs in the background, so you can execute more cells right away.\n", - "For example, create a second dataset and display it in another viewer:" + "`make_notebook_html()` generates a self-contained HTML string with the\n", + "WebGL viewer and all data embedded. Wrapping it in an IFrame via\n", + "`IPython.display.HTML` renders the interactive 3-D viewer inline." ] }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 2, + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-23T13:48:58.696787Z", + "iopub.status.busy": "2026-03-23T13:48:58.696307Z", + "iopub.status.idle": "2026-03-23T13:48:59.202722Z", + "shell.execute_reply": "2026-03-23T13:48:59.201421Z" + } + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/lib/python3.11/dist-packages/IPython/core/display.py:447: UserWarning: Consider using IPython.display.IFrame instead\n", + " warnings.warn(\"Consider using IPython.display.IFrame instead\")\n" + ] + }, + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "volume2 = cortex.Volume.random(subject='S1', xfmname='fullhead')\n", - "server2 = cortex.webgl.jupyter.display(volume2)" + "from IPython.display import HTML\n", + "\n", + "html = cortex.webgl.jupyter.make_notebook_html(volume)\n", + "HTML(\n", + " ''.format(srcdoc=html.replace('\"', '"'))\n", + ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Programmatic control via JSMixer\n", + "## IFrame mode (for live Jupyter sessions)\n", "\n", - "After the viewer has loaded in the IFrame, you can get a control handle.\n", + "When running in a live Jupyter session, the IFrame mode starts a Tornado\n", + "server in a background thread and embeds the viewer in an IFrame. This\n", + "provides full interactivity including surface morphing, data switching,\n", + "and programmatic control from Python via WebSocket.\n", "\n", - "> **Note:** `get_client()` blocks until the browser connects via WebSocket,\n", - "> so call it in a separate cell from `display()`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ + "```python\n", + "# Start the viewer (non-blocking)\n", + "server = cortex.webgl.jupyter.display(volume)\n", + "\n", + "# In a subsequent cell, get a control handle\n", "client = server.get_client()\n", "\n", "# Rotate the view\n", @@ -104,27 +74176,26 @@ "client._set_view(mix=1.0)\n", "\n", "# Capture a screenshot\n", - "client.getImage(\"notebook_screenshot.png\")" + "client.getImage(\"notebook_screenshot.png\")\n", + "```\n", + "\n", + "> **Note:** `get_client()` blocks until the browser connects via WebSocket,\n", + "> so call it in a **separate cell** from `display()`." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Method 2: Static mode\n", + "## Convenience wrapper: `display(method=\"static\")`\n", "\n", - "Generates a self-contained HTML viewer. The `make_static()` call takes\n", - "a few seconds to generate CTM meshes and embed resources, then serves\n", - "the result via a local HTTP server." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "cortex.webgl.jupyter.display(volume, method=\"static\")" + "For quick use in a live notebook, you can also call `display()` with\n", + "`method=\"static\"` which generates the static viewer and serves it via a\n", + "lightweight local HTTP server:\n", + "\n", + "```python\n", + "cortex.webgl.jupyter.display(volume, method=\"static\")\n", + "```" ] }, { @@ -134,42 +74205,17 @@ "## Customizing the viewer\n", "\n", "Both methods accept the same keyword arguments as `cortex.webgl.show()`\n", - "and `cortex.webgl.make_static()`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ + "and `cortex.webgl.make_static()`. For example:\n", + "\n", + "```python\n", "cortex.webgl.jupyter.display(\n", " volume,\n", " types=(\"inflated\",), # Surface types to include\n", " overlays_visible=(\"sulci\",), # Show sulci overlay by default\n", " height=400, # Shorter viewer\n", " title=\"My Experiment\",\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Lower-level: get raw HTML\n", - "\n", - "`make_notebook_html()` returns the self-contained HTML as a string,\n", - "useful for saving to disk or embedding in custom web pages." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "html = cortex.webgl.jupyter.make_notebook_html(volume)\n", - "print(\"Generated HTML: %d bytes\" % len(html))" + ")\n", + "```" ] } ], @@ -180,8 +74226,16 @@ "name": "python3" }, "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", "name": "python", - "version": "3.9.0" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.14" } }, "nbformat": 4, From 9d0bdc5d8674de9ad64a86aac77b4bcefc8138ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 15:16:57 +0000 Subject: [PATCH 07/15] Move notebook to docs/notebooks/ and strip cell outputs The executed outputs embedded ~4MB of HTML in the .ipynb (74k lines). Strip outputs so the notebook stays lightweight; nbsphinx is configured with nbsphinx_execute = 'never' so the docs render the code cells without re-executing. https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd --- docs/notebooks/jupyter_notebook.ipynb | 173 +- examples/webgl/jupyter_notebook.ipynb | 74243 ------------------------ 2 files changed, 172 insertions(+), 74244 deletions(-) mode change 120000 => 100644 docs/notebooks/jupyter_notebook.ipynb delete mode 100644 examples/webgl/jupyter_notebook.ipynb diff --git a/docs/notebooks/jupyter_notebook.ipynb b/docs/notebooks/jupyter_notebook.ipynb deleted file mode 120000 index 41480435e..000000000 --- a/docs/notebooks/jupyter_notebook.ipynb +++ /dev/null @@ -1 +0,0 @@ -../../examples/webgl/jupyter_notebook.ipynb \ No newline at end of file diff --git a/docs/notebooks/jupyter_notebook.ipynb b/docs/notebooks/jupyter_notebook.ipynb new file mode 100644 index 000000000..40471339f --- /dev/null +++ b/docs/notebooks/jupyter_notebook.ipynb @@ -0,0 +1,172 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Display WebGL Viewer in a Jupyter Notebook\n", + "\n", + "Pycortex can embed the interactive WebGL brain viewer directly in a Jupyter\n", + "notebook cell. There are two methods:\n", + "\n", + "1. **IFrame mode** (default): Starts a background Tornado server and embeds\n", + " it in an IFrame. Provides full interactivity including surface morphing,\n", + " data switching, and programmatic control from Python via WebSocket.\n", + "\n", + "2. **Static mode**: Generates a self-contained HTML viewer that can be\n", + " embedded inline. Works in static notebook renderers like nbviewer.\n", + "\n", + "Both methods are **non-blocking** — subsequent notebook cells execute immediately." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create some example data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-23T13:48:57.891694Z", + "iopub.status.busy": "2026-03-23T13:48:57.891451Z", + "iopub.status.idle": "2026-03-23T13:48:58.693304Z", + "shell.execute_reply": "2026-03-23T13:48:58.691981Z" + } + }, + "outputs": [], + "source": [ + "import cortex\n", + "import numpy as np\n", + "\n", + "np.random.seed(1234)\n", + "volume = cortex.Volume.random(subject='S1', xfmname='fullhead')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Static viewer\n", + "\n", + "`make_notebook_html()` generates a self-contained HTML string with the\n", + "WebGL viewer and all data embedded. Wrapping it in an IFrame via\n", + "`IPython.display.HTML` renders the interactive 3-D viewer inline." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-23T13:48:58.696787Z", + "iopub.status.busy": "2026-03-23T13:48:58.696307Z", + "iopub.status.idle": "2026-03-23T13:48:59.202722Z", + "shell.execute_reply": "2026-03-23T13:48:59.201421Z" + } + }, + "outputs": [], + "source": [ + "from IPython.display import HTML\n", + "\n", + "html = cortex.webgl.jupyter.make_notebook_html(volume)\n", + "HTML(\n", + " ''.format(srcdoc=html.replace('\"', '"'))\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## IFrame mode (for live Jupyter sessions)\n", + "\n", + "When running in a live Jupyter session, the IFrame mode starts a Tornado\n", + "server in a background thread and embeds the viewer in an IFrame. This\n", + "provides full interactivity including surface morphing, data switching,\n", + "and programmatic control from Python via WebSocket.\n", + "\n", + "```python\n", + "# Start the viewer (non-blocking)\n", + "server = cortex.webgl.jupyter.display(volume)\n", + "\n", + "# In a subsequent cell, get a control handle\n", + "client = server.get_client()\n", + "\n", + "# Rotate the view\n", + "client._set_view(azimuth=45, altitude=30)\n", + "\n", + "# Switch to inflated surface (mix=1.0 is fully inflated)\n", + "client._set_view(mix=1.0)\n", + "\n", + "# Capture a screenshot\n", + "client.getImage(\"notebook_screenshot.png\")\n", + "```\n", + "\n", + "> **Note:** `get_client()` blocks until the browser connects via WebSocket,\n", + "> so call it in a **separate cell** from `display()`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Convenience wrapper: `display(method=\"static\")`\n", + "\n", + "For quick use in a live notebook, you can also call `display()` with\n", + "`method=\"static\"` which generates the static viewer and serves it via a\n", + "lightweight local HTTP server:\n", + "\n", + "```python\n", + "cortex.webgl.jupyter.display(volume, method=\"static\")\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Customizing the viewer\n", + "\n", + "Both methods accept the same keyword arguments as `cortex.webgl.show()`\n", + "and `cortex.webgl.make_static()`. For example:\n", + "\n", + "```python\n", + "cortex.webgl.jupyter.display(\n", + " volume,\n", + " types=(\"inflated\",), # Surface types to include\n", + " overlays_visible=(\"sulci\",), # Show sulci overlay by default\n", + " height=400, # Shorter viewer\n", + " title=\"My Experiment\",\n", + ")\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.14" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/webgl/jupyter_notebook.ipynb b/examples/webgl/jupyter_notebook.ipynb deleted file mode 100644 index 6177bcf38..000000000 --- a/examples/webgl/jupyter_notebook.ipynb +++ /dev/null @@ -1,74243 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Display WebGL Viewer in a Jupyter Notebook\n", - "\n", - "Pycortex can embed the interactive WebGL brain viewer directly in a Jupyter\n", - "notebook cell. There are two methods:\n", - "\n", - "1. **IFrame mode** (default): Starts a background Tornado server and embeds\n", - " it in an IFrame. Provides full interactivity including surface morphing,\n", - " data switching, and programmatic control from Python via WebSocket.\n", - "\n", - "2. **Static mode**: Generates a self-contained HTML viewer that can be\n", - " embedded inline. Works in static notebook renderers like nbviewer.\n", - "\n", - "Both methods are **non-blocking** — subsequent notebook cells execute immediately." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Create some example data" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-23T13:48:57.891694Z", - "iopub.status.busy": "2026-03-23T13:48:57.891451Z", - "iopub.status.idle": "2026-03-23T13:48:58.693304Z", - "shell.execute_reply": "2026-03-23T13:48:58.691981Z" - } - }, - "outputs": [], - "source": [ - "import cortex\n", - "import numpy as np\n", - "\n", - "np.random.seed(1234)\n", - "volume = cortex.Volume.random(subject='S1', xfmname='fullhead')" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Static viewer\n", - "\n", - "`make_notebook_html()` generates a self-contained HTML string with the\n", - "WebGL viewer and all data embedded. Wrapping it in an IFrame via\n", - "`IPython.display.HTML` renders the interactive 3-D viewer inline." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-23T13:48:58.696787Z", - "iopub.status.busy": "2026-03-23T13:48:58.696307Z", - "iopub.status.idle": "2026-03-23T13:48:59.202722Z", - "shell.execute_reply": "2026-03-23T13:48:59.201421Z" - } - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/lib/python3.11/dist-packages/IPython/core/display.py:447: UserWarning: Consider using IPython.display.IFrame instead\n", - " warnings.warn(\"Consider using IPython.display.IFrame instead\")\n" - ] - }, - { - "data": { - "text/html": [ - "" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from IPython.display import HTML\n", - "\n", - "html = cortex.webgl.jupyter.make_notebook_html(volume)\n", - "HTML(\n", - " ''.format(srcdoc=html.replace('\"', '"'))\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## IFrame mode (for live Jupyter sessions)\n", - "\n", - "When running in a live Jupyter session, the IFrame mode starts a Tornado\n", - "server in a background thread and embeds the viewer in an IFrame. This\n", - "provides full interactivity including surface morphing, data switching,\n", - "and programmatic control from Python via WebSocket.\n", - "\n", - "```python\n", - "# Start the viewer (non-blocking)\n", - "server = cortex.webgl.jupyter.display(volume)\n", - "\n", - "# In a subsequent cell, get a control handle\n", - "client = server.get_client()\n", - "\n", - "# Rotate the view\n", - "client._set_view(azimuth=45, altitude=30)\n", - "\n", - "# Switch to inflated surface (mix=1.0 is fully inflated)\n", - "client._set_view(mix=1.0)\n", - "\n", - "# Capture a screenshot\n", - "client.getImage(\"notebook_screenshot.png\")\n", - "```\n", - "\n", - "> **Note:** `get_client()` blocks until the browser connects via WebSocket,\n", - "> so call it in a **separate cell** from `display()`." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Convenience wrapper: `display(method=\"static\")`\n", - "\n", - "For quick use in a live notebook, you can also call `display()` with\n", - "`method=\"static\"` which generates the static viewer and serves it via a\n", - "lightweight local HTTP server:\n", - "\n", - "```python\n", - "cortex.webgl.jupyter.display(volume, method=\"static\")\n", - "```" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Customizing the viewer\n", - "\n", - "Both methods accept the same keyword arguments as `cortex.webgl.show()`\n", - "and `cortex.webgl.make_static()`. For example:\n", - "\n", - "```python\n", - "cortex.webgl.jupyter.display(\n", - " volume,\n", - " types=(\"inflated\",), # Surface types to include\n", - " overlays_visible=(\"sulci\",), # Show sulci overlay by default\n", - " height=400, # Shorter viewer\n", - " title=\"My Experiment\",\n", - ")\n", - "```" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.14" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} From 3ed20eadd0ba06d3659f63afaf01c5fd87c35e67 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 15:17:29 +0000 Subject: [PATCH 08/15] Execute notebook at docs build time instead of committing outputs Set nbsphinx_execute = 'always' so the notebook cells run during sphinx-build. The committed .ipynb stays clean (no outputs). https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd --- docs/conf.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 914295e64..52eb9dd5b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -39,9 +39,8 @@ autosummary_generate = True numpydoc_show_class_members=False -# nbsphinx – render Jupyter notebooks in docs without re-executing them -nbsphinx_execute = 'never' -nbsphinx_custom_formats = {} +# nbsphinx – execute notebooks at docs build time +nbsphinx_execute = 'always' # Sphinx-gallery sphinx_gallery_conf = { From 92f9cb6e253bd113ed8acc37c8e8c84740228725 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 16:37:41 +0000 Subject: [PATCH 09/15] Move notebook under Example Gallery in docs index https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd --- docs/index.rst | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index b3891208e..cf289b9f6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -32,12 +32,6 @@ Example Gallery :maxdepth: 3 auto_examples/index - -Jupyter Notebooks ------------------ -.. toctree:: - :maxdepth: 2 - notebooks/jupyter_notebook API Reference From 46be3eb0e523899a297d0eebabc9e21e76101c80 Mon Sep 17 00:00:00 2001 From: Matteo Visconti di Oleggio Castello Date: Mon, 23 Mar 2026 09:52:15 -0700 Subject: [PATCH 10/15] Remove auto-generated sphinx-gallery execution times file This file is generated during docs build and should not be tracked. https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd --- docs/sg_execution_times.rst | 166 ------------------------------------ 1 file changed, 166 deletions(-) delete mode 100644 docs/sg_execution_times.rst diff --git a/docs/sg_execution_times.rst b/docs/sg_execution_times.rst deleted file mode 100644 index 342c71ae5..000000000 --- a/docs/sg_execution_times.rst +++ /dev/null @@ -1,166 +0,0 @@ - -:orphan: - -.. _sphx_glr_sg_execution_times: - - -Computation times -================= -**01:47.090** total execution time for 44 files **from all galleries**: - -.. container:: - - .. raw:: html - - - - - - - - .. list-table:: - :header-rows: 1 - :class: table table-striped sg-datatable - - * - Example - - Time - - Mem (MB) - * - :ref:`sphx_glr_auto_examples_utils_plot_roi_voxel_index_volume.py` (``../examples/utils/plot_roi_voxel_index_volume.py``) - - 00:32.321 - - 0.0 - * - :ref:`sphx_glr_auto_examples_surface_analyses_plot_interpolate_data.py` (``../examples/surface_analyses/plot_interpolate_data.py``) - - 00:20.223 - - 0.0 - * - :ref:`sphx_glr_auto_examples_surface_analyses_plot_geodesic_distance.py` (``../examples/surface_analyses/plot_geodesic_distance.py``) - - 00:11.786 - - 0.0 - * - :ref:`sphx_glr_auto_examples_surface_analyses_plot_geodesic_path.py` (``../examples/surface_analyses/plot_geodesic_path.py``) - - 00:08.047 - - 0.0 - * - :ref:`sphx_glr_auto_examples_surface_analyses_plot_flatmap_distortion.py` (``../examples/surface_analyses/plot_flatmap_distortion.py``) - - 00:05.612 - - 0.0 - * - :ref:`sphx_glr_auto_examples_utils_plot_roi_voxel_mask.py` (``../examples/utils/plot_roi_voxel_mask.py``) - - 00:04.531 - - 0.0 - * - :ref:`sphx_glr_auto_examples_utils_plot_voxel_distance_from_surface.py` (``../examples/utils/plot_voxel_distance_from_surface.py``) - - 00:03.540 - - 0.0 - * - :ref:`sphx_glr_auto_examples_utils_plot_get_roi_vertices.py` (``../examples/utils/plot_get_roi_vertices.py``) - - 00:03.393 - - 0.0 - * - :ref:`sphx_glr_auto_examples_datasets_plot_volume_to_vertex.py` (``../examples/datasets/plot_volume_to_vertex.py``) - - 00:02.325 - - 0.0 - * - :ref:`sphx_glr_auto_examples_quickflat_plot_advanced_compositing.py` (``../examples/quickflat/plot_advanced_compositing.py``) - - 00:01.476 - - 0.0 - * - :ref:`sphx_glr_auto_examples_quickflat_plot_dropout.py` (``../examples/quickflat/plot_dropout.py``) - - 00:01.456 - - 0.0 - * - :ref:`sphx_glr_auto_examples_quickflat_plot_cutouts.py` (``../examples/quickflat/plot_cutouts.py``) - - 00:00.945 - - 0.0 - * - :ref:`sphx_glr_auto_examples_quickflat_plot_sulci.py` (``../examples/quickflat/plot_sulci.py``) - - 00:00.894 - - 0.0 - * - :ref:`sphx_glr_auto_examples_quickflat_plot_thickness_nanmean.py` (``../examples/quickflat/plot_thickness_nanmean.py``) - - 00:00.889 - - 0.0 - * - :ref:`sphx_glr_auto_examples_quickflat_plot_connected_vertices.py` (``../examples/quickflat/plot_connected_vertices.py``) - - 00:00.833 - - 0.0 - * - :ref:`sphx_glr_auto_examples_datasets_plot_vertex.py` (``../examples/datasets/plot_vertex.py``) - - 00:00.742 - - 0.0 - * - :ref:`sphx_glr_auto_examples_datasets_plot_dataset_arithmetic.py` (``../examples/datasets/plot_dataset_arithmetic.py``) - - 00:00.715 - - 0.0 - * - :ref:`sphx_glr_auto_examples_quickflat_plot_make_svg.py` (``../examples/quickflat/plot_make_svg.py``) - - 00:00.630 - - 0.0 - * - :ref:`sphx_glr_auto_examples_quickflat_plot_make_png.py` (``../examples/quickflat/plot_make_png.py``) - - 00:00.623 - - 0.0 - * - :ref:`sphx_glr_auto_examples_quickflat_plot_zoom_to_roi.py` (``../examples/quickflat/plot_zoom_to_roi.py``) - - 00:00.598 - - 0.0 - * - :ref:`sphx_glr_auto_examples_quickflat_plot_rois.py` (``../examples/quickflat/plot_rois.py``) - - 00:00.581 - - 0.0 - * - :ref:`sphx_glr_auto_examples_quickflat_plot_make_gif.py` (``../examples/quickflat/plot_make_gif.py``) - - 00:00.570 - - 0.0 - * - :ref:`sphx_glr_auto_examples_datasets_plot_volume.py` (``../examples/datasets/plot_volume.py``) - - 00:00.546 - - 0.0 - * - :ref:`sphx_glr_auto_examples_datasets_plot_volume2D.py` (``../examples/datasets/plot_volume2D.py``) - - 00:00.538 - - 0.0 - * - :ref:`sphx_glr_auto_examples_surface_analyses_plot_subsurfaces.py` (``../examples/surface_analyses/plot_subsurfaces.py``) - - 00:00.528 - - 0.0 - * - :ref:`sphx_glr_auto_examples_quickflat_plot_make_figure.py` (``../examples/quickflat/plot_make_figure.py``) - - 00:00.527 - - 0.0 - * - :ref:`sphx_glr_auto_examples_datasets_plot_volumeRGB.py` (``../examples/datasets/plot_volumeRGB.py``) - - 00:00.509 - - 0.0 - * - :ref:`sphx_glr_auto_examples_datasets_plot_vertexRGB.py` (``../examples/datasets/plot_vertexRGB.py``) - - 00:00.501 - - 0.0 - * - :ref:`sphx_glr_auto_examples_datasets_plot_vertex2D.py` (``../examples/datasets/plot_vertex2D.py``) - - 00:00.497 - - 0.0 - * - :ref:`sphx_glr_auto_examples_surface_analyses_plot_tissots_indicatrix.py` (``../examples/surface_analyses/plot_tissots_indicatrix.py``) - - 00:00.446 - - 0.0 - * - :ref:`sphx_glr_auto_examples_utils_plot_mosaic.py` (``../examples/utils/plot_mosaic.py``) - - 00:00.236 - - 0.0 - * - :ref:`sphx_glr_auto_examples_quickstart_plot_retinotopy_flatmap.py` (``../examples/quickstart/plot_retinotopy_flatmap.py``) - - 00:00.032 - - 0.0 - * - :ref:`sphx_glr_auto_examples_fsaverage_upsample_to_fsaverage.py` (``../examples/fsaverage/upsample_to_fsaverage.py``) - - 00:00.000 - - 0.0 - * - :ref:`sphx_glr_auto_examples_import_surface_import_fmriprep.py` (``../examples/import_surface/import_fmriprep.py``) - - 00:00.000 - - 0.0 - * - :ref:`sphx_glr_auto_examples_quickstart_retinotopy_webgl.py` (``../examples/quickstart/retinotopy_webgl.py``) - - 00:00.000 - - 0.0 - * - :ref:`sphx_glr_auto_examples_quickstart_show_config.py` (``../examples/quickstart/show_config.py``) - - 00:00.000 - - 0.0 - * - :ref:`sphx_glr_auto_examples_utils_mni_to_subject.py` (``../examples/utils/mni_to_subject.py``) - - 00:00.000 - - 0.0 - * - :ref:`sphx_glr_auto_examples_utils_multi_panels_plots.py` (``../examples/utils/multi_panels_plots.py``) - - 00:00.000 - - 0.0 - * - :ref:`sphx_glr_auto_examples_utils_subject_to_mni.py` (``../examples/utils/subject_to_mni.py``) - - 00:00.000 - - 0.0 - * - :ref:`sphx_glr_auto_examples_webgl_dynamic_with_custom_template.py` (``../examples/webgl/dynamic_with_custom_template.py``) - - 00:00.000 - - 0.0 - * - :ref:`sphx_glr_auto_examples_webgl_multiple_datasets.py` (``../examples/webgl/multiple_datasets.py``) - - 00:00.000 - - 0.0 - * - :ref:`sphx_glr_auto_examples_webgl_single_dataset.py` (``../examples/webgl/single_dataset.py``) - - 00:00.000 - - 0.0 - * - :ref:`sphx_glr_auto_examples_webgl_static.py` (``../examples/webgl/static.py``) - - 00:00.000 - - 0.0 - * - :ref:`sphx_glr_auto_examples_webgl_static_with_custom_template.py` (``../examples/webgl/static_with_custom_template.py``) - - 00:00.000 - - 0.0 From e4c9cbc8add6bb99c7ab20a8491bf6700465cdbf Mon Sep 17 00:00:00 2001 From: Matteo Visconti di Oleggio Castello Date: Mon, 23 Mar 2026 10:19:01 -0700 Subject: [PATCH 11/15] Fix bugs and improve Jupyter WebGL widget - Fix invalid port 65536 by using socket.bind(('', 0)) to find free ports - Remove dead html_content read in display_static - Tighten import guard: only suppress ImportError when IPython is missing - Add StaticViewer class with close() for server/tmpdir cleanup - Wrap make_static in try/except with clear RuntimeError on failure - Log HTTP 4xx/5xx errors instead of suppressing all server output - Use TemporaryDirectory context manager in make_notebook_html - Use port 0 for HTTPServer to get OS-assigned free port - Expand tests from 10 to 20: dispatch, port handling, cleanup, errors --- cortex/tests/test_jupyter_widget.py | 232 ++++++++++++++++++++-------- cortex/webgl/__init__.py | 5 +- cortex/webgl/jupyter.py | 152 +++++++++++++----- 3 files changed, 285 insertions(+), 104 deletions(-) diff --git a/cortex/tests/test_jupyter_widget.py b/cortex/tests/test_jupyter_widget.py index 403b7158e..49557ce16 100644 --- a/cortex/tests/test_jupyter_widget.py +++ b/cortex/tests/test_jupyter_widget.py @@ -1,119 +1,208 @@ """Tests for the Jupyter WebGL widget integration.""" + import os -import tempfile +import re import unittest from unittest.mock import MagicMock, patch -import numpy as np - 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, "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')) - def test_display_invalid_method(self): + 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_iframe_calls_show(self, mock_show, mock_display): - """Test that display_iframe calls show() with correct params.""" + @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_server = MagicMock() - mock_show.return_value = mock_server - + mock_show.return_value = MagicMock() result = display_iframe("fake_data", port=9999) mock_show.assert_called_once() - call_kwargs = mock_show.call_args - self.assertFalse(call_kwargs.kwargs.get('open_browser', True)) - self.assertFalse(call_kwargs.kwargs.get('autoclose', True)) - self.assertEqual(result, mock_server) - - @patch('cortex.webgl.jupyter.ipydisplay') - @patch('cortex.webgl.view.show') - def test_iframe_displays_iframe(self, mock_show, mock_display): - """Test that an IFrame is displayed.""" + 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, height=400) + 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.""" - @patch('cortex.webgl.jupyter.ipydisplay') - @patch('cortex.webgl.view.make_static') - def test_static_creates_tempdir(self, mock_make_static, mock_display): - """Test that display_static creates a temp directory and HTML.""" - from cortex.webgl.jupyter import display_static - - # Mock make_static to create a fake index.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("test") + 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("test") - mock_make_static.side_effect = fake_make_static + @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() - call_kwargs = mock_make_static.call_args - self.assertTrue(call_kwargs.kwargs.get('html_embed', False)) + self.assertTrue(mock_make_static.call_args.kwargs.get("html_embed", False)) mock_display.assert_called_once() - # Result should be an IFrame - from IPython.display import IFrame - self.assertIsInstance(result, IFrame) + 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") - @patch('cortex.webgl.jupyter.ipydisplay') - @patch('cortex.webgl.view.make_static') - def test_static_width_int(self, mock_make_static, mock_display): - """Test integer width is converted to px string.""" + 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 - 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("test") + mock_make_static.side_effect = Exception("make_static failed") - mock_make_static.side_effect = fake_make_static + 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() - from IPython.display import IFrame - self.assertIsInstance(result, IFrame) + result.close() class TestMakeNotebookHtml(unittest.TestCase): """Test the raw HTML generation function.""" - @patch('cortex.webgl.view.make_static') + @patch("cortex.webgl.view.make_static") def test_returns_html_string(self, mock_make_static): - """Test that make_notebook_html returns an HTML string.""" from cortex.webgl.jupyter import make_notebook_html def fake_make_static(outpath, data, **kwargs): @@ -127,21 +216,42 @@ def fake_make_static(outpath, data, **kwargs): self.assertIn("", 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("test") + + 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): - """Test that notebook.html template exists.""" 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) + self.assertTrue( + os.path.exists(template_path), + "notebook.html template not found at %s" % template_path, + ) def test_template_extends_base(self): - """Test that notebook.html extends template.html.""" 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: @@ -151,5 +261,5 @@ def test_template_extends_base(self): self.assertIn("block jsinit", content) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/cortex/webgl/__init__.py b/cortex/webgl/__init__.py index 3542b25b0..94f612370 100644 --- a/cortex/webgl/__init__.py +++ b/cortex/webgl/__init__.py @@ -15,7 +15,8 @@ make_static = DocLoader("make_static", ".view", "cortex.webgl", actual_func=_static) try: - from . import jupyter + import IPython # noqa: F401 except ImportError: - # IPython not available pass +else: + from . import jupyter diff --git a/cortex/webgl/jupyter.py b/cortex/webgl/jupyter.py index 5689f484c..061f807bf 100644 --- a/cortex/webgl/jupyter.py +++ b/cortex/webgl/jupyter.py @@ -14,15 +14,72 @@ >>> vol = cortex.Volume.random("S1", "fullhead") >>> cortex.webgl.jupyter.display(vol) # auto-detects best approach """ -import json + +import http.server +import logging import os -import random +import shutil +import socket import tempfile -import warnings +import threading from IPython.display import HTML, IFrame from IPython.display import display as ipydisplay +logger = logging.getLogger(__name__) + + +def _find_free_port(): + """Find a free TCP port by binding to port 0 and reading the OS-assigned port.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +class StaticViewer: + """Handle for a static viewer served via a local HTTP server. + + Call ``close()`` to shut down the server and clean up temp files. + + Attributes + ---------- + iframe : IPython.display.IFrame + The IFrame used to display the viewer. + """ + + def __init__(self, iframe, httpd, thread, tmpdir): + self.iframe = iframe + self._httpd = httpd + self._thread = thread + self._tmpdir = tmpdir + + def close(self): + """Shut down the HTTP server and remove temp files.""" + try: + self._httpd.shutdown() + except Exception: + logger.warning("Failed to shut down static viewer server", exc_info=True) + try: + shutil.rmtree(self._tmpdir, ignore_errors=True) + except Exception: + logger.warning( + "Failed to clean up temp dir %s", self._tmpdir, exc_info=True + ) + + def __del__(self): + try: + self._httpd.shutdown() + except Exception: + pass + try: + shutil.rmtree(self._tmpdir, ignore_errors=True) + except Exception: + pass + + def _repr_html_(self): + """Allow Jupyter to display this object directly.""" + return self.iframe._repr_html_() + def display(data, method="iframe", width="100%", height=600, **kwargs): """Display brain data in a Jupyter notebook using the WebGL viewer. @@ -43,8 +100,8 @@ def display(data, method="iframe", width="100%", height=600, **kwargs): Returns ------- - For "iframe": the server object (JSMixer or WebApp) - For "static": the IPython HTML display object + For "iframe": the server object (WebApp) + For "static": a StaticViewer handle (call ``.close()`` to clean up) """ if method == "iframe": return display_iframe(data, width=width, height=height, **kwargs) @@ -70,7 +127,8 @@ def display_iframe(data, width="100%", height=600, port=None, **kwargs): height : int, optional IFrame height in pixels. Default 600. port : int or None, optional - Port for the Tornado server. If None, a random port is chosen. + Port for the Tornado server. If None, a free port is chosen + automatically. **kwargs Additional keyword arguments passed to ``cortex.webgl.show()``. @@ -83,11 +141,11 @@ def display_iframe(data, width="100%", height=600, port=None, **kwargs): from . import view, serve if port is None: - port = random.randint(1024, 65536) + port = _find_free_port() # Start the server without opening a browser - kwargs['open_browser'] = False - kwargs['autoclose'] = False + kwargs["open_browser"] = False + kwargs["autoclose"] = False server = view.show(data, port=port, **kwargs) url = "http://%s:%d/mixer.html" % (serve.hostname, port) @@ -105,8 +163,7 @@ def display_static(data, width="100%", height=600, **kwargs): """Display brain data as a self-contained HTML viewer inline. Generates a complete static viewer with all JS/CSS/data embedded, - then displays it in the notebook. This works in static notebook - renderers like nbviewer and GitHub. + then displays it in the notebook via a lightweight local HTTP server. Note: The embedded HTML is large (~4-5MB) because all JavaScript libraries and CSS are inlined. @@ -124,22 +181,31 @@ def display_static(data, width="100%", height=600, **kwargs): Returns ------- - iframe : IPython.display.IFrame - The IFrame display object. + viewer : StaticViewer + Handle for the static viewer. Call ``viewer.close()`` to shut down the + HTTP server and clean up temporary files. """ from . import view - # Create a temporary directory for the static viewer tmpdir = tempfile.mkdtemp(prefix="pycortex_jupyter_") outpath = os.path.join(tmpdir, "viewer") - # Generate the static viewer - view.make_static(outpath, data, html_embed=True, **kwargs) + try: + view.make_static(outpath, data, html_embed=True, **kwargs) + except Exception as e: + shutil.rmtree(tmpdir, ignore_errors=True) + raise RuntimeError( + "Failed to generate static viewer. " + "Check that data is valid and cortex is properly configured." + ) from e - # Read the generated HTML index_html = os.path.join(outpath, "index.html") - with open(index_html, "r") as f: - html_content = f.read() + if not os.path.isfile(index_html): + shutil.rmtree(tmpdir, ignore_errors=True) + raise FileNotFoundError( + "make_static() did not produce index.html. " + "This may indicate a problem with the static template." + ) # Format width if isinstance(width, int): @@ -147,29 +213,32 @@ def display_static(data, width="100%", height=600, **kwargs): else: width_str = width - # Serve via a minimal local HTTP server to avoid srcdoc size limits - # and cross-origin issues with data URIs in the embedded HTML - import http.server - import threading - - # Find a free port - port = random.randint(10000, 65000) - - class QuietHandler(http.server.SimpleHTTPRequestHandler): + class _QuietHandler(http.server.SimpleHTTPRequestHandler): def __init__(self, *args, **handler_kwargs): super().__init__(*args, directory=outpath, **handler_kwargs) def log_message(self, format, *args): - pass # Suppress log output in notebook + # Log HTTP errors, suppress routine access logs + if args and len(args) >= 2: + try: + status = int(args[1]) + if status >= 400: + logger.warning("Static viewer HTTP %s: %s", args[1], args[0]) + except (ValueError, IndexError): + pass + + httpd = http.server.HTTPServer(("127.0.0.1", 0), _QuietHandler) + port = httpd.server_address[1] - httpd = http.server.HTTPServer(("127.0.0.1", port), QuietHandler) thread = threading.Thread(target=httpd.serve_forever, daemon=True) thread.start() - iframe = IFrame(src="http://127.0.0.1:%d/index.html" % port, - width=width_str, height=height) + iframe = IFrame( + src="http://127.0.0.1:%d/index.html" % port, width=width_str, height=height + ) ipydisplay(iframe) - return iframe + + return StaticViewer(iframe, httpd, thread, tmpdir) def make_notebook_html(data, template="static.html", types=("inflated",), **kwargs): @@ -194,13 +263,14 @@ def make_notebook_html(data, template="static.html", types=("inflated",), **kwar html : str The self-contained HTML string. """ - tmpdir = tempfile.mkdtemp(prefix="pycortex_nb_") - outpath = os.path.join(tmpdir, "viewer") - from . import view - view.make_static(outpath, data, template=template, types=types, - html_embed=True, **kwargs) - index_html = os.path.join(outpath, "index.html") - with open(index_html, "r") as f: - return f.read() + with tempfile.TemporaryDirectory(prefix="pycortex_nb_") as tmpdir: + outpath = os.path.join(tmpdir, "viewer") + view.make_static( + outpath, data, template=template, types=types, html_embed=True, **kwargs + ) + + index_html = os.path.join(outpath, "index.html") + with open(index_html, "r") as f: + return f.read() From f8c87564afc2504959bf8870e3ea8b66219ed19b Mon Sep 17 00:00:00 2001 From: Matteo Visconti di Oleggio Castello Date: Mon, 23 Mar 2026 10:32:55 -0700 Subject: [PATCH 12/15] Fix notebook to use make_static directory instead of srcdoc The previous approach embedded ~4MB of HTML in an iframe srcdoc attribute, which broke JavaScript due to escaping issues. Now the notebook calls make_static() to generate the full viewer directory (HTML + CTM + data files) and references it via a regular IFrame src. Added a build-finished hook in conf.py to copy the generated static_viewer/ directory into the Sphinx build output. Verified with Playwright: brain renders correctly in ~2s. https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd --- docs/conf.py | 224 ++++++++++++++------------ docs/notebooks/.gitignore | 3 + docs/notebooks/jupyter_notebook.ipynb | 59 +++---- 3 files changed, 144 insertions(+), 142 deletions(-) create mode 100644 docs/notebooks/.gitignore diff --git a/docs/conf.py b/docs/conf.py index 52eb9dd5b..4150b6399 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,61 +16,81 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('sphinxext')) -sys.path.insert(0, os.path.abspath('..')) +# sys.path.insert(0, os.path.abspath('sphinxext')) +sys.path.insert(0, os.path.abspath("..")) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', - 'sphinx.ext.todo', - 'sphinx.ext.mathjax', - 'sphinx.ext.viewcode', - 'sphinx.ext.autosummary', - 'numpydoc', - 'sphinx.ext.githubpages', - 'sphinx_gallery.gen_gallery', - 'nbsphinx'] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.todo", + "sphinx.ext.mathjax", + "sphinx.ext.viewcode", + "sphinx.ext.autosummary", + "numpydoc", + "sphinx.ext.githubpages", + "sphinx_gallery.gen_gallery", + "nbsphinx", +] autosummary_generate = True -numpydoc_show_class_members=False +numpydoc_show_class_members = False # nbsphinx – execute notebooks at docs build time -nbsphinx_execute = 'always' +nbsphinx_execute = "always" + + +def _copy_notebook_artifacts(app, exception): + """Copy static viewer files generated by notebook execution into the + build output so IFrame references resolve correctly.""" + import shutil + + src = os.path.join(app.srcdir, "notebooks", "static_viewer") + dst = os.path.join(app.outdir, "notebooks", "static_viewer") + if os.path.isdir(src) and not os.path.isdir(dst): + shutil.copytree(src, dst) + + +def setup(app): + app.connect("build-finished", _copy_notebook_artifacts) + # Sphinx-gallery sphinx_gallery_conf = { # path to your examples scripts - 'examples_dirs' : '../examples', + "examples_dirs": "../examples", # path where to save gallery generated examples - 'gallery_dirs' : 'auto_examples', + "gallery_dirs": "auto_examples", # which files to execute? only those starting with "plot_" - 'filename_pattern' : '/plot_'} + "filename_pattern": "/plot_", +} # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The main toctree document. -main_doc = 'index' +main_doc = "index" # General information about the project. -project = u'pycortex' -copyright = u'2012, James Gao' +project = "pycortex" +copyright = "2012, James Gao" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. import cortex # noqa + # The short X.Y version. version = ".".join(cortex.__version__.split(".")[:2]) # The full version, including alpha/beta/rc tags. @@ -78,23 +98,23 @@ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build', 'auto_examples/**/*.ipynb'] +exclude_patterns = ["_build", "auto_examples/**/*.ipynb"] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). @@ -102,161 +122,155 @@ # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'alabaster' +html_theme = "alabaster" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. html_theme_options = { # 'logo': 'logo.png', - 'github_user': 'gallantlab', - 'github_repo': 'pycortex', - 'github_type': 'star', + "github_user": "gallantlab", + "github_repo": "pycortex", + "github_type": "star", } # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} html_sidebars = { - '**': [ - 'about.html', - 'navigation.html', - 'relations.html', - 'searchbox.html', - 'donate.html', + "**": [ + "about.html", + "navigation.html", + "relations.html", + "searchbox.html", + "donate.html", ] } # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'pycortexdoc' +htmlhelp_basename = "pycortexdoc" # -- Options for LaTeX output -------------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'pycortex.tex', u'pycortex Documentation', - u'James Gao', 'manual'), + ("index", "pycortex.tex", "pycortex Documentation", "James Gao", "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'pycortex', u'pycortex Documentation', - [u'James Gao'], 1) -] +man_pages = [("index", "pycortex", "pycortex Documentation", ["James Gao"], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ @@ -265,59 +279,65 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'pycortex', u'pycortex Documentation', - u'James Gao', 'pycortex', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "pycortex", + "pycortex Documentation", + "James Gao", + "pycortex", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # -- Options for Epub output --------------------------------------------------- # Bibliographic Dublin Core info. -epub_title = u'pycortex' -epub_author = u'James Gao' -epub_publisher = u'James Gao' -epub_copyright = u'2012, James Gao' +epub_title = "pycortex" +epub_author = "James Gao" +epub_publisher = "James Gao" +epub_copyright = "2012, James Gao" # The language of the text. It defaults to the language option # or en if the language is not set. -#epub_language = '' +# epub_language = '' # The scheme of the identifier. Typical schemes are ISBN or URL. -#epub_scheme = '' +# epub_scheme = '' # The unique identifier of the text. This can be a ISBN number # or the project homepage. -#epub_identifier = '' +# epub_identifier = '' # A unique identification for the text. -#epub_uid = '' +# epub_uid = '' # A tuple containing the cover image and cover page html template filenames. -#epub_cover = () +# epub_cover = () # HTML files that should be inserted before the pages created by sphinx. # The format is a list of tuples containing the path and title. -#epub_pre_files = [] +# epub_pre_files = [] # HTML files that should be inserted after the pages created by sphinx. # The format is a list of tuples containing the path and title. -#epub_post_files = [] +# epub_post_files = [] # A list of files that should not be packed into the epub file. -#epub_exclude_files = [] +# epub_exclude_files = [] # The depth of the table of contents in toc.ncx. -#epub_tocdepth = 3 +# epub_tocdepth = 3 # Allow duplicate toc entries. -#epub_tocdup = True +# epub_tocdup = True diff --git a/docs/notebooks/.gitignore b/docs/notebooks/.gitignore new file mode 100644 index 000000000..2fcc2d401 --- /dev/null +++ b/docs/notebooks/.gitignore @@ -0,0 +1,3 @@ +# Generated during notebook execution by nbsphinx +static_viewer/ +viewer.html diff --git a/docs/notebooks/jupyter_notebook.ipynb b/docs/notebooks/jupyter_notebook.ipynb index 40471339f..248dfe673 100644 --- a/docs/notebooks/jupyter_notebook.ipynb +++ b/docs/notebooks/jupyter_notebook.ipynb @@ -13,8 +13,8 @@ " it in an IFrame. Provides full interactivity including surface morphing,\n", " data switching, and programmatic control from Python via WebSocket.\n", "\n", - "2. **Static mode**: Generates a self-contained HTML viewer that can be\n", - " embedded inline. Works in static notebook renderers like nbviewer.\n", + "2. **Static mode**: Generates a self-contained HTML viewer served by a\n", + " lightweight local HTTP server. Works in static notebook renderers.\n", "\n", "Both methods are **non-blocking** — subsequent notebook cells execute immediately." ] @@ -29,21 +29,14 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-23T13:48:57.891694Z", - "iopub.status.busy": "2026-03-23T13:48:57.891451Z", - "iopub.status.idle": "2026-03-23T13:48:58.693304Z", - "shell.execute_reply": "2026-03-23T13:48:58.691981Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "import cortex\n", "import numpy as np\n", "\n", "np.random.seed(1234)\n", - "volume = cortex.Volume.random(subject='S1', xfmname='fullhead')" + "volume = cortex.Volume.random(subject=\"S1\", xfmname=\"fullhead\")" ] }, { @@ -52,32 +45,23 @@ "source": [ "## Static viewer\n", "\n", - "`make_notebook_html()` generates a self-contained HTML string with the\n", - "WebGL viewer and all data embedded. Wrapping it in an IFrame via\n", - "`IPython.display.HTML` renders the interactive 3-D viewer inline." + "`cortex.webgl.make_static()` generates a self-contained viewer directory\n", + "with the HTML, CTM surface meshes, and data files. We write it to a\n", + "local directory and embed it in an IFrame." ] }, { "cell_type": "code", "execution_count": null, - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-23T13:48:58.696787Z", - "iopub.status.busy": "2026-03-23T13:48:58.696307Z", - "iopub.status.idle": "2026-03-23T13:48:59.202722Z", - "shell.execute_reply": "2026-03-23T13:48:59.201421Z" - } - }, + "metadata": {}, "outputs": [], "source": [ - "from IPython.display import HTML\n", - "\n", - "html = cortex.webgl.jupyter.make_notebook_html(volume)\n", - "HTML(\n", - " ''.format(srcdoc=html.replace('\"', '"'))\n", - ")" + "from IPython.display import IFrame\n", + "\n", + "# Generate the full static viewer (HTML + CTM + data files)\n", + "cortex.webgl.make_static(\"static_viewer\", volume, html_embed=True)\n", + "\n", + "IFrame(src=\"static_viewer/index.html\", width=\"100%\", height=\"600px\")" ] }, { @@ -123,7 +107,10 @@ "lightweight local HTTP server:\n", "\n", "```python\n", - "cortex.webgl.jupyter.display(volume, method=\"static\")\n", + "viewer = cortex.webgl.jupyter.display(volume, method=\"static\")\n", + "\n", + "# When done, clean up the server and temp files:\n", + "viewer.close()\n", "```" ] }, @@ -155,16 +142,8 @@ "name": "python3" }, "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.14" + "version": "3.9.0" } }, "nbformat": 4, From 85c05280a1b3d36c9a41dde384208f6f794d6f66 Mon Sep 17 00:00:00 2001 From: Matteo Visconti di Oleggio Castello Date: Mon, 23 Mar 2026 10:41:02 -0700 Subject: [PATCH 13/15] Add nbsphinx, ipykernel, and pandoc to docs CI workflow The Jupyter notebook example requires nbsphinx (Sphinx extension), ipykernel (notebook execution), and pandoc (notebook conversion) to build during CI. https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd --- docs/.github/workflows/build_docs.yml | 64 +++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/.github/workflows/build_docs.yml diff --git a/docs/.github/workflows/build_docs.yml b/docs/.github/workflows/build_docs.yml new file mode 100644 index 000000000..fa7c2e1e6 --- /dev/null +++ b/docs/.github/workflows/build_docs.yml @@ -0,0 +1,64 @@ +name: Build docs + +on: + push: + branches: + - main + tags: + - '*' + pull_request: + branches: + - main + workflow_dispatch: # Manual trigger for publishing docs + +jobs: + build-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: 3.12 + + - uses: actions/cache@v5 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py', '**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y inkscape --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 -e . --no-build-isolation --group dev + python -c 'import cortex; print(cortex.__full_version__)' + + - name: Cache Playwright browsers + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('**/pyproject.toml') }} + + - name: Install Playwright Chromium + run: playwright install --with-deps --only-shell chromium + + - name: Build documents + timeout-minutes: 20 + run: | + cd docs && make html && cd .. + touch docs/_build/html/.nojekyll + + - name: Publish to gh-pages if tagged + if: startsWith(github.ref, 'refs/tags') || github.event_name == 'workflow_dispatch' + uses: JamesIves/github-pages-deploy-action@v4.8.0 + with: + branch: gh-pages + folder: docs/_build/html From c6c586852fc60b7ccdb942dea31e333ab0eeb3ee Mon Sep 17 00:00:00 2001 From: Matteo Visconti di Oleggio Castello Date: Mon, 23 Mar 2026 10:45:14 -0700 Subject: [PATCH 14/15] Make nbsphinx optional in docs build The upstream CI workflow doesn't install nbsphinx. Make it optional: only add the extension if importable, otherwise exclude the notebooks directory so the build doesn't fail on .ipynb files. Also keep the workflow update (nbsphinx + pandoc + ipykernel) for when the workflow is merged upstream. https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd --- .github/workflows/build_docs.yml | 4 ++-- docs/conf.py | 21 ++++++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_docs.yml b/.github/workflows/build_docs.yml index fa7c2e1e6..e60cc5c9a 100644 --- a/.github/workflows/build_docs.yml +++ b/.github/workflows/build_docs.yml @@ -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__)' diff --git a/docs/conf.py b/docs/conf.py index 4150b6399..5f61aa8a6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,15 +35,25 @@ "numpydoc", "sphinx.ext.githubpages", "sphinx_gallery.gen_gallery", - "nbsphinx", ] +exclude_patterns = ["_build", "auto_examples/**/*.ipynb"] + +# nbsphinx – render and execute Jupyter notebooks in docs +# Only enable if nbsphinx (and its deps: pandoc, ipykernel) are installed +try: + import nbsphinx # noqa: F401 + + extensions.append("nbsphinx") + nbsphinx_execute = "always" +except ImportError: + # nbsphinx not installed – exclude notebook sources so the build + # doesn't fail on unrecognised .ipynb files in the toctree + exclude_patterns.append("notebooks") + autosummary_generate = True numpydoc_show_class_members = False -# nbsphinx – execute notebooks at docs build time -nbsphinx_execute = "always" - def _copy_notebook_artifacts(app, exception): """Copy static viewer files generated by notebook execution into the @@ -108,7 +118,8 @@ def setup(app): # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ["_build", "auto_examples/**/*.ipynb"] +# (exclude_patterns is defined near the top of this file, before the +# nbsphinx conditional block that may append to it) # The reST default role (used for this markup: `text`) to use for all documents. # default_role = None From 76904cf2bcd90007b5a561b171de0b6d4dd37cd0 Mon Sep 17 00:00:00 2001 From: Matteo Visconti di Oleggio Castello Date: Tue, 24 Mar 2026 08:17:52 -0700 Subject: [PATCH 15/15] Address Copilot review comments on Jupyter widget PR - Remove unused HTML import - Make StaticViewer.close() idempotent with server_close() and thread.join() - Add viewer registry with close_all() and atexit cleanup to prevent tmp buildup - Fix misleading docstrings (auto-detect claim, self-contained HTML claims) - Make static host configurable via CORTEX_JUPYTER_STATIC_HOST env var - Add TOCTOU race documentation to _find_free_port - Make nbsphinx a required docs dependency (fixes toctree mismatch) - Fix _copy_notebook_artifacts to skip on failure and overwrite stale builds - Remove misplaced docs/.github/workflows/build_docs.yml - Fix test_width_int_converted to actually assert IFrame width - Add tests for viewer registry and close_all --- cortex/tests/test_jupyter_widget.py | 51 +++++++++ cortex/webgl/jupyter.py | 142 ++++++++++++++++++++------ docs/.github/workflows/build_docs.yml | 64 ------------ docs/conf.py | 23 ++--- 4 files changed, 169 insertions(+), 111 deletions(-) delete mode 100644 docs/.github/workflows/build_docs.yml diff --git a/cortex/tests/test_jupyter_widget.py b/cortex/tests/test_jupyter_widget.py index 49557ce16..3b3ec8db2 100644 --- a/cortex/tests/test_jupyter_widget.py +++ b/cortex/tests/test_jupyter_widget.py @@ -17,6 +17,7 @@ def test_import_module(self): self.assertTrue(hasattr(jupyter, "display_static")) self.assertTrue(hasattr(jupyter, "make_notebook_html")) self.assertTrue(hasattr(jupyter, "StaticViewer")) + self.assertTrue(hasattr(jupyter, "close_all")) def test_import_from_webgl(self): import cortex @@ -195,9 +196,59 @@ def test_width_int_converted(self, mock_make_static, mock_display): result = display_static("fake_data", width=800) mock_display.assert_called_once() + iframe_arg = mock_display.call_args[0][0] + self.assertEqual("800px", iframe_arg.width) result.close() +class TestViewerRegistry(unittest.TestCase): + """Test the active viewer registry and close_all.""" + + 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("test") + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_viewer_registered_on_create(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import _active_viewers, display_static + + mock_make_static.side_effect = self._fake_make_static + viewer = display_static("fake_data") + + self.assertIn(viewer, _active_viewers) + viewer.close() + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_close_all_cleans_up(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import close_all, display_static + + mock_make_static.side_effect = self._fake_make_static + v1 = display_static("fake_data") + v2 = display_static("fake_data") + + self.assertTrue(os.path.isdir(v1._tmpdir)) + self.assertTrue(os.path.isdir(v2._tmpdir)) + + close_all() + + self.assertFalse(os.path.isdir(v1._tmpdir)) + self.assertFalse(os.path.isdir(v2._tmpdir)) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_close_all_idempotent(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import close_all, display_static + + mock_make_static.side_effect = self._fake_make_static + viewer = display_static("fake_data") + viewer.close() + # Should not raise even though viewer is already closed + close_all() + + class TestMakeNotebookHtml(unittest.TestCase): """Test the raw HTML generation function.""" diff --git a/cortex/webgl/jupyter.py b/cortex/webgl/jupyter.py index 061f807bf..1a66530a6 100644 --- a/cortex/webgl/jupyter.py +++ b/cortex/webgl/jupyter.py @@ -5,16 +5,18 @@ 1. **IFrame-based** (``display_iframe``): Starts a Tornado server and embeds the viewer in an IFrame. Full interactivity with WebSocket support. -2. **Static HTML** (``display_static``): Generates a self-contained HTML viewer - with all resources embedded. Works in static notebooks (nbviewer, GitHub). +2. **Static viewer** (``display_static``): Generates a static viewer directory + served via a local HTTP server and embedded in an IFrame. Requires a live + Jupyter environment (will not work in static notebook renderers). Usage ----- >>> import cortex >>> vol = cortex.Volume.random("S1", "fullhead") ->>> cortex.webgl.jupyter.display(vol) # auto-detects best approach +>>> cortex.webgl.jupyter.display(vol) # defaults to iframe method """ +import atexit import http.server import logging import os @@ -22,15 +24,46 @@ import socket import tempfile import threading +import weakref -from IPython.display import HTML, IFrame +from IPython.display import IFrame from IPython.display import display as ipydisplay logger = logging.getLogger(__name__) +# Registry of active StaticViewer instances for cleanup. +# Uses weak references so viewers that are garbage-collected don't linger here. +_active_viewers = weakref.WeakSet() +_viewer_lock = threading.Lock() + + +def close_all(): + """Close all active static viewers, shutting down servers and removing temp files.""" + with _viewer_lock: + viewers = list(_active_viewers) + closed = 0 + for viewer in viewers: + try: + viewer.close() + closed += 1 + except Exception: + logger.warning("Failed to close viewer during close_all", exc_info=True) + if closed: + logger.info("Closed %d static viewer(s)", closed) + + +atexit.register(close_all) + def _find_free_port(): - """Find a free TCP port by binding to port 0 and reading the OS-assigned port.""" + """Find a free TCP port by binding to port 0 and reading the OS-assigned port. + + Note: There is an inherent TOCTOU race between releasing this socket and + the caller binding the port. For ``display_static`` this is avoided by + binding ``HTTPServer`` to port 0 directly. For ``display_iframe`` the + underlying Tornado server does not support port 0, so this helper is used + as a best-effort fallback. + """ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("", 0)) return s.getsockname()[1] @@ -52,27 +85,52 @@ def __init__(self, iframe, httpd, thread, tmpdir): self._httpd = httpd self._thread = thread self._tmpdir = tmpdir - - def close(self): - """Shut down the HTTP server and remove temp files.""" - try: - self._httpd.shutdown() - except Exception: - logger.warning("Failed to shut down static viewer server", exc_info=True) - try: - shutil.rmtree(self._tmpdir, ignore_errors=True) - except Exception: - logger.warning( - "Failed to clean up temp dir %s", self._tmpdir, exc_info=True - ) + self._closed = False + self._lock = threading.Lock() + with _viewer_lock: + _active_viewers.add(self) + + def close(self, timeout=1.0): + """Shut down the HTTP server, wait for the thread, and remove temp files. + + Parameters + ---------- + timeout : float, optional + Maximum seconds to wait for the server thread to finish. + """ + with self._lock: + if self._closed: + return + self._closed = True + + if self._httpd is not None: + try: + self._httpd.shutdown() + self._httpd.server_close() + except Exception: + logger.warning( + "Failed to shut down static viewer server", exc_info=True + ) + + if self._thread is not None and self._thread.is_alive(): + try: + self._thread.join(timeout=timeout) + except Exception: + logger.warning( + "Failed to join static viewer server thread", exc_info=True + ) + + if self._tmpdir is not None: + try: + shutil.rmtree(self._tmpdir, ignore_errors=True) + except Exception: + logger.warning( + "Failed to clean up temp dir %s", self._tmpdir, exc_info=True + ) def __del__(self): try: - self._httpd.shutdown() - except Exception: - pass - try: - shutil.rmtree(self._tmpdir, ignore_errors=True) + self.close(timeout=0.1) except Exception: pass @@ -160,13 +218,23 @@ def display_iframe(data, width="100%", height=600, port=None, **kwargs): def display_static(data, width="100%", height=600, **kwargs): - """Display brain data as a self-contained HTML viewer inline. + """Display brain data using a temporary static WebGL viewer inline. - Generates a complete static viewer with all JS/CSS/data embedded, - then displays it in the notebook via a lightweight local HTTP server. + Uses ``cortex.webgl.make_static`` to generate a *directory* containing + ``index.html`` plus all required JS/CSS/data assets, then serves that + directory via a lightweight local HTTP server and embeds it in the + notebook inside an IFrame. - Note: The embedded HTML is large (~4-5MB) because all JavaScript - libraries and CSS are inlined. + Note + ---- + The output is **not** a single self-contained HTML string; it is a static + viewer directory that must be served for the page to function. This works + in live Jupyter environments but most static notebook renderers will not + display the interactive viewer. + + The bind host defaults to ``127.0.0.1``. For remote notebook setups + (JupyterHub, SSH tunnels), set the ``CORTEX_JUPYTER_STATIC_HOST`` + environment variable to the appropriate hostname. Parameters ---------- @@ -227,14 +295,16 @@ def log_message(self, format, *args): except (ValueError, IndexError): pass - httpd = http.server.HTTPServer(("127.0.0.1", 0), _QuietHandler) + host = os.environ.get("CORTEX_JUPYTER_STATIC_HOST", "127.0.0.1") + + httpd = http.server.HTTPServer((host, 0), _QuietHandler) port = httpd.server_address[1] thread = threading.Thread(target=httpd.serve_forever, daemon=True) thread.start() iframe = IFrame( - src="http://127.0.0.1:%d/index.html" % port, width=width_str, height=height + src="http://%s:%d/index.html" % (host, port), width=width_str, height=height ) ipydisplay(iframe) @@ -242,10 +312,14 @@ def log_message(self, format, *args): def make_notebook_html(data, template="static.html", types=("inflated",), **kwargs): - """Generate a self-contained HTML string for the WebGL viewer. + """Generate the ``index.html`` for a static WebGL viewer. - This is a lower-level function that returns the raw HTML string rather - than displaying it. Useful for saving or embedding in custom contexts. + This is a lower-level function that returns the raw HTML string produced + by ``make_static()``. Note that the HTML references external asset files + (CTM meshes, JSON data, PNG colormaps) that ``make_static()`` writes + alongside ``index.html``. The returned string alone is **not** a fully + self-contained viewer -- it must be served from a directory containing + those assets for the viewer to function. Parameters ---------- @@ -261,7 +335,7 @@ def make_notebook_html(data, template="static.html", types=("inflated",), **kwar Returns ------- html : str - The self-contained HTML string. + The generated HTML string (requires adjacent assets to function). """ from . import view diff --git a/docs/.github/workflows/build_docs.yml b/docs/.github/workflows/build_docs.yml deleted file mode 100644 index fa7c2e1e6..000000000 --- a/docs/.github/workflows/build_docs.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Build docs - -on: - push: - branches: - - main - tags: - - '*' - pull_request: - branches: - - main - workflow_dispatch: # Manual trigger for publishing docs - -jobs: - build-docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: 3.12 - - - uses: actions/cache@v5 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py', '**/pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-pip- - - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y inkscape --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 -e . --no-build-isolation --group dev - python -c 'import cortex; print(cortex.__full_version__)' - - - name: Cache Playwright browsers - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('**/pyproject.toml') }} - - - name: Install Playwright Chromium - run: playwright install --with-deps --only-shell chromium - - - name: Build documents - timeout-minutes: 20 - run: | - cd docs && make html && cd .. - touch docs/_build/html/.nojekyll - - - name: Publish to gh-pages if tagged - if: startsWith(github.ref, 'refs/tags') || github.event_name == 'workflow_dispatch' - uses: JamesIves/github-pages-deploy-action@v4.8.0 - with: - branch: gh-pages - folder: docs/_build/html diff --git a/docs/conf.py b/docs/conf.py index 5f61aa8a6..b5cc1566f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,7 +11,8 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os +import sys +import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -39,17 +40,8 @@ exclude_patterns = ["_build", "auto_examples/**/*.ipynb"] -# nbsphinx – render and execute Jupyter notebooks in docs -# Only enable if nbsphinx (and its deps: pandoc, ipykernel) are installed -try: - import nbsphinx # noqa: F401 - - extensions.append("nbsphinx") - nbsphinx_execute = "always" -except ImportError: - # nbsphinx not installed – exclude notebook sources so the build - # doesn't fail on unrecognised .ipynb files in the toctree - exclude_patterns.append("notebooks") +extensions.append("nbsphinx") +nbsphinx_execute = "always" autosummary_generate = True numpydoc_show_class_members = False @@ -58,11 +50,16 @@ def _copy_notebook_artifacts(app, exception): """Copy static viewer files generated by notebook execution into the build output so IFrame references resolve correctly.""" + if exception is not None: + return + import shutil src = os.path.join(app.srcdir, "notebooks", "static_viewer") dst = os.path.join(app.outdir, "notebooks", "static_viewer") - if os.path.isdir(src) and not os.path.isdir(dst): + if os.path.isdir(src): + if os.path.isdir(dst): + shutil.rmtree(dst) shutil.copytree(src, dst)