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/cortex/tests/test_jupyter_widget.py b/cortex/tests/test_jupyter_widget.py new file mode 100644 index 000000000..3b3ec8db2 --- /dev/null +++ b/cortex/tests/test_jupyter_widget.py @@ -0,0 +1,316 @@ +"""Tests for the Jupyter WebGL widget integration.""" + +import os +import re +import unittest +from unittest.mock import MagicMock, patch + + +class TestJupyterImports(unittest.TestCase): + """Test that the jupyter module imports correctly.""" + + def test_import_module(self): + from cortex.webgl import jupyter + + self.assertTrue(hasattr(jupyter, "display")) + self.assertTrue(hasattr(jupyter, "display_iframe")) + self.assertTrue(hasattr(jupyter, "display_static")) + self.assertTrue(hasattr(jupyter, "make_notebook_html")) + self.assertTrue(hasattr(jupyter, "StaticViewer")) + self.assertTrue(hasattr(jupyter, "close_all")) + + def test_import_from_webgl(self): + import cortex + + self.assertTrue(hasattr(cortex.webgl, "jupyter")) + + +class TestFindFreePort(unittest.TestCase): + """Test the _find_free_port helper.""" + + def test_returns_valid_port(self): + from cortex.webgl.jupyter import _find_free_port + + port = _find_free_port() + self.assertIsInstance(port, int) + self.assertGreaterEqual(port, 1024) + self.assertLessEqual(port, 65535) + + def test_returns_different_ports(self): + from cortex.webgl.jupyter import _find_free_port + + ports = {_find_free_port() for _ in range(10)} + self.assertGreater(len(ports), 1) + + +class TestDisplay(unittest.TestCase): + """Test the display() dispatch function.""" + + def test_invalid_method(self): + from cortex.webgl.jupyter import display + + with self.assertRaises(ValueError): + display(None, method="invalid") + + @patch("cortex.webgl.jupyter.display_iframe") + def test_dispatch_iframe(self, mock_iframe): + from cortex.webgl.jupyter import display + + display("fake_data", method="iframe") + mock_iframe.assert_called_once() + + @patch("cortex.webgl.jupyter.display_static") + def test_dispatch_static(self, mock_static): + from cortex.webgl.jupyter import display + + display("fake_data", method="static") + mock_static.assert_called_once() + + @patch("cortex.webgl.jupyter.display_iframe") + def test_forwards_kwargs(self, mock_iframe): + from cortex.webgl.jupyter import display + + display("fake_data", method="iframe", width=800, height=400, port=5555) + mock_iframe.assert_called_once_with( + "fake_data", width=800, height=400, port=5555 + ) + + +class TestDisplayIframe(unittest.TestCase): + """Test the IFrame-based display method.""" + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.show") + def test_calls_show_with_correct_params(self, mock_show, mock_display): + from cortex.webgl.jupyter import display_iframe + + mock_show.return_value = MagicMock() + result = display_iframe("fake_data", port=9999) + + mock_show.assert_called_once() + call_kwargs = mock_show.call_args.kwargs + self.assertFalse(call_kwargs.get("open_browser", True)) + self.assertFalse(call_kwargs.get("autoclose", True)) + self.assertEqual(call_kwargs["port"], 9999) + self.assertEqual(result, mock_show.return_value) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.show") + def test_iframe_url_contains_port(self, mock_show, mock_display): + from cortex.webgl.jupyter import display_iframe + from IPython.display import IFrame + + mock_show.return_value = MagicMock() + display_iframe("fake_data", port=8888) + + mock_display.assert_called_once() + iframe_arg = mock_display.call_args[0][0] + self.assertIsInstance(iframe_arg, IFrame) + self.assertIn("8888", iframe_arg.src) + self.assertIn("mixer.html", iframe_arg.src) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.show") + def test_auto_port_is_valid(self, mock_show, mock_display): + from cortex.webgl.jupyter import display_iframe + + mock_show.return_value = MagicMock() + display_iframe("fake_data") + + port = mock_show.call_args.kwargs["port"] + self.assertGreaterEqual(port, 1024) + self.assertLessEqual(port, 65535) + + +class TestDisplayStatic(unittest.TestCase): + """Test the static HTML display method.""" + + def _fake_make_static(self, outpath, data, **kwargs): + os.makedirs(outpath, exist_ok=True) + with open(os.path.join(outpath, "index.html"), "w") as f: + f.write("
test") + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_returns_static_viewer(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import display_static, StaticViewer + + mock_make_static.side_effect = self._fake_make_static + result = display_static("fake_data", height=500) + + mock_make_static.assert_called_once() + self.assertTrue(mock_make_static.call_args.kwargs.get("html_embed", False)) + mock_display.assert_called_once() + self.assertIsInstance(result, StaticViewer) + self.assertTrue(hasattr(result, "close")) + self.assertTrue(hasattr(result, "iframe")) + result.close() + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_close_cleans_tmpdir(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + result = display_static("fake_data") + + self.assertTrue(os.path.isdir(result._tmpdir)) + result.close() + self.assertFalse(os.path.isdir(result._tmpdir)) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_make_static_failure_raises_runtime_error( + self, mock_make_static, mock_display + ): + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = Exception("make_static failed") + + with self.assertRaises(RuntimeError) as ctx: + display_static("fake_data") + self.assertIn("Failed to generate static viewer", str(ctx.exception)) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_uses_os_assigned_port(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + result = display_static("fake_data") + + iframe_arg = mock_display.call_args[0][0] + match = re.search(r":(\d+)/", iframe_arg.src) + self.assertIsNotNone(match) + port = int(match.group(1)) + self.assertGreaterEqual(port, 1024) + self.assertLessEqual(port, 65535) + result.close() + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_width_int_converted(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + result = display_static("fake_data", width=800) + + mock_display.assert_called_once() + 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.""" + + @patch("cortex.webgl.view.make_static") + def test_returns_html_string(self, mock_make_static): + from cortex.webgl.jupyter import make_notebook_html + + def fake_make_static(outpath, data, **kwargs): + os.makedirs(outpath, exist_ok=True) + with open(os.path.join(outpath, "index.html"), "w") as f: + f.write("viewer") + + mock_make_static.side_effect = fake_make_static + + html = make_notebook_html("fake_data") + 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): + import cortex.webgl + + template_dir = os.path.dirname(cortex.webgl.__file__) + template_path = os.path.join(template_dir, "notebook.html") + self.assertTrue( + os.path.exists(template_path), + "notebook.html template not found at %s" % template_path, + ) + + def test_template_extends_base(self): + import cortex.webgl + + template_dir = os.path.dirname(cortex.webgl.__file__) + template_path = os.path.join(template_dir, "notebook.html") + with open(template_path) as f: + content = f.read() + self.assertIn("extends template.html", content) + self.assertIn("block onload", content) + self.assertIn("block jsinit", content) + + +if __name__ == "__main__": + unittest.main() diff --git a/cortex/webgl/__init__.py b/cortex/webgl/__init__.py index b31971b5e..94f612370 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,10 @@ show = DocLoader("show", ".view", "cortex.webgl", actual_func=_show) make_static = DocLoader("make_static", ".view", "cortex.webgl", actual_func=_static) + +try: + import IPython # noqa: F401 +except ImportError: + pass +else: + from . import jupyter diff --git a/cortex/webgl/jupyter.py b/cortex/webgl/jupyter.py new file mode 100644 index 000000000..1a66530a6 --- /dev/null +++ b/cortex/webgl/jupyter.py @@ -0,0 +1,350 @@ +"""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 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) # defaults to iframe method +""" + +import atexit +import http.server +import logging +import os +import shutil +import socket +import tempfile +import threading +import weakref + +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. + + 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] + + +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 + 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.close(timeout=0.1) + 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. + + 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 (WebApp) + For "static": a StaticViewer handle (call ``.close()`` to clean up) + """ + 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 free port is chosen + automatically. + **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 = _find_free_port() + + # 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 using a temporary static WebGL viewer inline. + + 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 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 + ---------- + 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 + ------- + viewer : StaticViewer + Handle for the static viewer. Call ``viewer.close()`` to shut down the + HTTP server and clean up temporary files. + """ + from . import view + + tmpdir = tempfile.mkdtemp(prefix="pycortex_jupyter_") + outpath = os.path.join(tmpdir, "viewer") + + 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 + + index_html = os.path.join(outpath, "index.html") + 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): + width_str = "%dpx" % width + else: + width_str = width + + class _QuietHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **handler_kwargs): + super().__init__(*args, directory=outpath, **handler_kwargs) + + def log_message(self, format, *args): + # 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 + + 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://%s:%d/index.html" % (host, port), width=width_str, height=height + ) + ipydisplay(iframe) + + return StaticViewer(iframe, httpd, thread, tmpdir) + + +def make_notebook_html(data, template="static.html", types=("inflated",), **kwargs): + """Generate the ``index.html`` for a static WebGL viewer. + + 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 + ---------- + 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 generated HTML string (requires adjacent assets to function). + """ + from . import view + + 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() 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 %} diff --git a/docs/conf.py b/docs/conf.py index 38020b289..b5cc1566f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,62 +11,93 @@ # 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 # 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'] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.todo", + "sphinx.ext.mathjax", + "sphinx.ext.viewcode", + "sphinx.ext.autosummary", + "numpydoc", + "sphinx.ext.githubpages", + "sphinx_gallery.gen_gallery", +] + +exclude_patterns = ["_build", "auto_examples/**/*.ipynb"] + +extensions.append("nbsphinx") +nbsphinx_execute = "always" autosummary_generate = True -numpydoc_show_class_members=False +numpydoc_show_class_members = False + + +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): + if os.path.isdir(dst): + shutil.rmtree(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. @@ -74,23 +105,24 @@ # 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'] +# (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 +# 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::). @@ -98,161 +130,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 # "