diff --git a/.github/workflows/build_docs.yml b/.github/workflows/build_docs.yml index 004d8e54..c45ce8a1 100644 --- a/.github/workflows/build_docs.yml +++ b/.github/workflows/build_docs.yml @@ -34,12 +34,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 00000000..910861d4 --- /dev/null +++ b/cortex/tests/test_jupyter_widget.py @@ -0,0 +1,931 @@ +"""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_all_ports_in_valid_range(self): + """Issue #24: replace flaky uniqueness test with range validation.""" + from cortex.webgl.jupyter import _find_free_port + + for _ in range(10): + port = _find_free_port() + self.assertIsInstance(port, int) + self.assertGreaterEqual(port, 1024) + self.assertLessEqual(port, 65535) + + +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 TestNotebookTemplateRemoved(unittest.TestCase): + """Verify dead notebook.html template was removed (issue #3).""" + + def test_notebook_template_does_not_exist(self): + import cortex.webgl + + template_dir = os.path.dirname(cortex.webgl.__file__) + template_path = os.path.join(template_dir, "notebook.html") + self.assertFalse( + os.path.exists(template_path), + "notebook.html should have been removed (dead code)", + ) + + +class TestDisplayIframeUrl(unittest.TestCase): + """Test that display_iframe uses correct URL host and suppresses display_url.""" + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.show") + def test_iframe_url_uses_localhost_not_hostname(self, mock_show, mock_display): + """Issue #1: iframe URL should use 127.0.0.1, not socket.gethostname().""" + from cortex.webgl.jupyter import display_iframe + + mock_show.return_value = MagicMock() + display_iframe("fake_data", port=7777) + + iframe_arg = mock_display.call_args[0][0] + # Should use 127.0.0.1 (or env-configurable host), not machine hostname + self.assertTrue( + iframe_arg.src.startswith("http://127.0.0.1:7777/"), + "IFrame URL should use 127.0.0.1, got: %s" % iframe_arg.src, + ) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.show") + def test_iframe_host_configurable_via_env(self, mock_show, mock_display): + """Issue #1: iframe host should be configurable via env var.""" + from cortex.webgl.jupyter import display_iframe + + mock_show.return_value = MagicMock() + with patch.dict(os.environ, {"CORTEX_JUPYTER_IFRAME_HOST": "myhost.local"}): + display_iframe("fake_data", port=7777) + + iframe_arg = mock_display.call_args[0][0] + self.assertIn("myhost.local", iframe_arg.src) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.show") + def test_show_called_with_display_url_false(self, mock_show, mock_display): + """Issue #2: show() should be called with display_url=False.""" + from cortex.webgl.jupyter import display_iframe + + mock_show.return_value = MagicMock() + display_iframe("fake_data", port=9999) + + call_kwargs = mock_show.call_args.kwargs + self.assertFalse( + call_kwargs.get("display_url", True), + "show() should be called with display_url=False", + ) + + +class TestStaticViewerClosedProperty(unittest.TestCase): + """Test the public `closed` property on StaticViewer.""" + + 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_closed_property_false_before_close(self, mock_make_static, mock_display): + """Issue #10: StaticViewer should have a public `closed` property.""" + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + viewer = display_static("fake_data") + self.assertFalse(viewer.closed) + viewer.close() + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_closed_property_true_after_close(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + viewer = display_static("fake_data") + viewer.close() + self.assertTrue(viewer.closed) + + +class TestStaticViewerReprHtml(unittest.TestCase): + """Test _repr_html_ behavior including after close.""" + + 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_repr_html_returns_string(self, mock_make_static, mock_display): + """Issue #21: _repr_html_() should return a non-empty string.""" + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + viewer = display_static("fake_data") + html = viewer._repr_html_() + self.assertIsInstance(html, str) + self.assertGreater(len(html), 0) + viewer.close() + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_repr_html_after_close_shows_closed_message( + self, mock_make_static, mock_display + ): + """Issue #9: _repr_html_() after close should show a closed message, + not a broken iframe.""" + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + viewer = display_static("fake_data") + viewer.close() + html = viewer._repr_html_() + self.assertIn("closed", html.lower()) + + +class TestStaticViewerContextManager(unittest.TestCase): + """Test that StaticViewer supports the context manager protocol.""" + + 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_context_manager_closes_on_exit(self, mock_make_static, mock_display): + """Issue #11: StaticViewer should support `with` statement.""" + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + viewer = display_static("fake_data") + tmpdir = viewer._tmpdir + + with viewer: + self.assertTrue(os.path.isdir(tmpdir)) + + self.assertFalse(os.path.isdir(tmpdir)) + self.assertTrue(viewer.closed) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_context_manager_returns_self(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + viewer = display_static("fake_data") + + with viewer as v: + self.assertIs(v, viewer) + + viewer.close() + + +class TestStaticViewerDoubleClose(unittest.TestCase): + """Test that close() is idempotent when called directly twice.""" + + 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_double_close_no_error(self, mock_make_static, mock_display): + """Issue #18: calling close() twice directly should not raise.""" + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + viewer = display_static("fake_data") + viewer.close() + viewer.close() # should not raise + + +class TestCloseGracefulDegradation(unittest.TestCase): + """Test that close() cleans up remaining resources even if httpd.shutdown fails.""" + + 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_tmpdir_cleaned_even_if_httpd_shutdown_fails( + self, mock_make_static, mock_display + ): + """Issue #19: tmpdir should be cleaned up even when httpd.shutdown() raises.""" + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + viewer = display_static("fake_data") + tmpdir = viewer._tmpdir + + # Force httpd.shutdown to fail + viewer._httpd.shutdown = MagicMock(side_effect=OSError("shutdown failed")) + + viewer.close() + self.assertFalse(os.path.isdir(tmpdir)) + + +class TestCloseThreadJoinVerification(unittest.TestCase): + """Test that close() warns when thread.join times out.""" + + 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_warns_when_thread_still_alive_after_join( + self, mock_make_static, mock_display + ): + """Issue #14: should warn if thread doesn't stop after join timeout.""" + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + viewer = display_static("fake_data") + + # Replace thread with one that reports alive after join + mock_thread = MagicMock() + mock_thread.is_alive.return_value = True + viewer._thread = mock_thread + + with self.assertLogs("cortex.webgl.jupyter", level="WARNING") as cm: + viewer.close(timeout=0.01) + + self.assertTrue( + any("did not terminate" in msg for msg in cm.output), + "Expected warning about thread not terminating, got: %s" % cm.output, + ) + + +class TestCloseAllLockScope(unittest.TestCase): + """Test that close_all releases the lock before blocking shutdown.""" + + 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_lock_not_held_during_close(self, mock_make_static, mock_display): + """Issue #4: _viewer_lock should NOT be held during blocking close() calls.""" + from cortex.webgl.jupyter import _viewer_lock, close_all, display_static + + mock_make_static.side_effect = self._fake_make_static + viewer = display_static("fake_data") + + lock_held_during_close = [] + original_close = viewer.close + + def spy_close(*args, **kwargs): + # Check if we can acquire the lock (non-blocking). + # If close_all holds the lock, this will return False. + acquired = _viewer_lock.acquire(blocking=False) + lock_held_during_close.append(not acquired) + if acquired: + _viewer_lock.release() + return original_close(*args, **kwargs) + + viewer.close = spy_close + close_all() + + self.assertTrue(len(lock_held_during_close) > 0) + self.assertFalse( + lock_held_during_close[0], + "close_all() held _viewer_lock during viewer.close() — risk of deadlock", + ) + + +class TestDisplayStaticResourceLeak(unittest.TestCase): + """Test resource cleanup when display_static fails after server starts.""" + + 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.view.make_static") + def test_cleanup_when_ipydisplay_fails(self, mock_make_static): + """Issue #7: resources should be cleaned up if ipydisplay raises.""" + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + + with patch( + "cortex.webgl.jupyter.ipydisplay", side_effect=RuntimeError("no display") + ): + with self.assertRaises(RuntimeError): + display_static("fake_data") + + # After the exception, there should be no leaked threads serving HTTP + # (we can't easily check threads, but we can verify no leaked tmpdirs + # by checking that the function cleaned up) + # The key assertion is that no exception is swallowed and resources + # are not leaked. Since tmpdir is internal, we verify via the + # active_viewers registry. + from cortex.webgl.jupyter import _active_viewers + + # No viewer should have been registered since construction failed + self.assertEqual(len(list(_active_viewers)), 0) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_cleanup_when_httpserver_fails(self, mock_make_static, mock_display): + """Issue #8: tmpdir should be cleaned when HTTPServer fails to bind.""" + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + + created_tmpdirs = [] + original_mkdtemp = __import__("tempfile").mkdtemp + + def tracking_mkdtemp(**kwargs): + d = original_mkdtemp(**kwargs) + created_tmpdirs.append(d) + return d + + with patch( + "cortex.webgl.jupyter.tempfile.mkdtemp", side_effect=tracking_mkdtemp + ): + with patch( + "cortex.webgl.jupyter.http.server.HTTPServer", + side_effect=OSError("bind failed"), + ): + with self.assertRaises((OSError, RuntimeError)): + display_static("fake_data") + + # tmpdir should have been cleaned up + for d in created_tmpdirs: + self.assertFalse( + os.path.isdir(d), + "Temp directory leaked after HTTPServer failure: %s" % d, + ) + + +class TestDisplayStaticMissingIndexHtml(unittest.TestCase): + """Test FileNotFoundError when make_static doesn't produce index.html.""" + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_missing_index_html_raises_and_cleans_up( + self, mock_make_static, mock_display + ): + """Issue #17: should raise FileNotFoundError and clean up tmpdir.""" + from cortex.webgl.jupyter import display_static + + def fake_make_static_no_index(outpath, data, **kwargs): + os.makedirs(outpath, exist_ok=True) + # Write some file but NOT index.html + with open(os.path.join(outpath, "data.json"), "w") as f: + f.write("{}") + + mock_make_static.side_effect = fake_make_static_no_index + + with self.assertRaises(FileNotFoundError): + display_static("fake_data") + + +class TestStaticHostEnvVar(unittest.TestCase): + """Test CORTEX_JUPYTER_STATIC_HOST env var.""" + + 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_static_host_from_env(self, mock_make_static, mock_display): + """Issue #20: CORTEX_JUPYTER_STATIC_HOST should change the iframe host.""" + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + + with patch.dict(os.environ, {"CORTEX_JUPYTER_STATIC_HOST": "0.0.0.0"}): + viewer = display_static("fake_data") + + iframe_arg = mock_display.call_args[0][0] + self.assertIn("0.0.0.0", iframe_arg.src) + viewer.close() + + +class TestQuietHandlerLogging(unittest.TestCase): + """Test that _QuietHandler logs errors but suppresses routine access logs.""" + + 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_serves_content_and_logs_errors(self, mock_make_static, mock_display): + """Issue #15/#22: HTTP server should serve content, log errors, + suppress routine access logs.""" + import urllib.request + + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + viewer = display_static("fake_data") + + # Extract port from iframe + iframe_arg = mock_display.call_args[0][0] + match = re.search(r":(\d+)/", iframe_arg.src) + port = int(match.group(1)) + + # Successful request should work + resp = urllib.request.urlopen("http://127.0.0.1:%d/index.html" % port) + self.assertEqual(resp.status, 200) + content = resp.read().decode() + self.assertIn("test", content) + + # 404 should be logged + with self.assertLogs("cortex.webgl.jupyter", level="WARNING") as cm: + try: + urllib.request.urlopen("http://127.0.0.1:%d/nonexistent" % port) + except urllib.error.HTTPError: + pass + + self.assertTrue( + any("404" in msg or "nonexistent" in msg for msg in cm.output), + "404 errors should be logged, got: %s" % cm.output, + ) + + viewer.close() + + +class TestFindFreePortFlaky(unittest.TestCase): + """Fix for issue #24: flaky port uniqueness test.""" + + def test_returns_valid_port_range(self): + """Ports should be in the valid ephemeral range.""" + from cortex.webgl.jupyter import _find_free_port + + for _ in range(10): + port = _find_free_port() + self.assertIsInstance(port, int) + self.assertGreaterEqual(port, 1024) + self.assertLessEqual(port, 65535) + + +class TestViewerRemoveFromRegistryOnClose(unittest.TestCase): + """Test that closed viewers are removed from _active_viewers.""" + + 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_removed_from_registry_on_close( + 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() + self.assertNotIn(viewer, _active_viewers) + + +class TestMakeNotebookHtmlKwargs(unittest.TestCase): + """Test that make_notebook_html forwards kwargs correctly.""" + + @patch("cortex.webgl.view.make_static") + def test_forwards_template_and_types(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("test") + + mock_make_static.side_effect = fake_make_static + + make_notebook_html("fake_data", template="notebook.html", types=("fiducial",)) + + call_kwargs = mock_make_static.call_args.kwargs + self.assertEqual(call_kwargs.get("template"), "notebook.html") + self.assertEqual(call_kwargs.get("types"), ("fiducial",)) + self.assertTrue(call_kwargs.get("html_embed", False)) + + +class TestInitImportProtection(unittest.TestCase): + """Test that broken jupyter import doesn't break cortex.webgl.""" + + def test_webgl_importable_with_broken_jupyter(self): + """Issue #16: broken jupyter.py should not break cortex.webgl.""" + # This is tested implicitly — if the import guard is correct, + # cortex.webgl will import even if jupyter submodule fails. + # We verify the guard pattern exists. + import cortex.webgl + + # Module should be importable regardless + self.assertTrue(hasattr(cortex.webgl, "show")) + self.assertTrue(hasattr(cortex.webgl, "make_static")) + + +class TestDisplayStaticOutputDir(unittest.TestCase): + """Test display_static with output_dir parameter.""" + + 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_output_dir_uses_relative_iframe_src(self, mock_make_static, mock_display): + """When output_dir is given, IFrame src should be a relative path.""" + import tempfile + + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + tmpdir = tempfile.mkdtemp() + outdir = os.path.join(tmpdir, "my_viewer") + try: + viewer = display_static("fake_data", output_dir=outdir) + iframe_arg = mock_display.call_args[0][0] + self.assertEqual(iframe_arg.src, os.path.join(outdir, "index.html")) + viewer.close() + finally: + import shutil + + shutil.rmtree(tmpdir, ignore_errors=True) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_output_dir_no_http_server(self, mock_make_static, mock_display): + """When output_dir is given, no HTTP server should be started.""" + import tempfile + + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + tmpdir = tempfile.mkdtemp() + outdir = os.path.join(tmpdir, "my_viewer") + try: + viewer = display_static("fake_data", output_dir=outdir) + self.assertIsNone(viewer._httpd) + self.assertIsNone(viewer._thread) + viewer.close() + finally: + import shutil + + shutil.rmtree(tmpdir, ignore_errors=True) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_output_dir_close_does_not_delete(self, mock_make_static, mock_display): + """When output_dir is given, close() should NOT delete the directory.""" + import tempfile + + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + tmpdir = tempfile.mkdtemp() + outdir = os.path.join(tmpdir, "my_viewer") + try: + viewer = display_static("fake_data", output_dir=outdir) + viewer.close() + self.assertTrue(os.path.isdir(outdir)) + self.assertTrue(os.path.isfile(os.path.join(outdir, "index.html"))) + finally: + import shutil + + shutil.rmtree(tmpdir, ignore_errors=True) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_output_dir_passes_kwargs_to_make_static( + self, mock_make_static, mock_display + ): + """output_dir should still forward kwargs to make_static.""" + import tempfile + + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + tmpdir = tempfile.mkdtemp() + outdir = os.path.join(tmpdir, "my_viewer") + try: + viewer = display_static("fake_data", output_dir=outdir) + call_args = mock_make_static.call_args + self.assertEqual(call_args[0][0], outdir) + self.assertTrue(call_args[1].get("html_embed", False)) + viewer.close() + finally: + import shutil + + shutil.rmtree(tmpdir, ignore_errors=True) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_display_forwards_output_dir(self, mock_make_static, mock_display): + """display() should forward output_dir to display_static.""" + import tempfile + + from cortex.webgl.jupyter import display + + mock_make_static.side_effect = self._fake_make_static + tmpdir = tempfile.mkdtemp() + outdir = os.path.join(tmpdir, "my_viewer") + try: + viewer = display("fake_data", method="static", output_dir=outdir) + iframe_arg = mock_display.call_args[0][0] + self.assertEqual(iframe_arg.src, os.path.join(outdir, "index.html")) + viewer.close() + finally: + import shutil + + shutil.rmtree(tmpdir, ignore_errors=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/cortex/webgl/__init__.py b/cortex/webgl/__init__.py index b31971b5..1b26bd8c 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: + import IPython # noqa: F401 + from . import jupyter # noqa: F401 +except ImportError: + pass diff --git a/cortex/webgl/jupyter.py b/cortex/webgl/jupyter.py new file mode 100644 index 00000000..e9ef2069 --- /dev/null +++ b/cortex/webgl/jupyter.py @@ -0,0 +1,414 @@ +"""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.""" + try: + with _viewer_lock: + viewers = list(_active_viewers) + except Exception: + return # Module teardown in progress + closed = 0 + for viewer in viewers: + try: + viewer.close() + closed += 1 + except Exception: + try: + logger.warning("Failed to close viewer during close_all", exc_info=True) + except Exception: + pass + if closed: + try: + logger.info("Closed %d static viewer(s)", closed) + except Exception: + pass + + +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) + + @property + def closed(self): + """Whether this viewer has been closed.""" + return self._closed + + 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) + if self._thread.is_alive(): + logger.warning( + "Static viewer server thread did not terminate " + "within %.1fs 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 + ) + + with _viewer_lock: + _active_viewers.discard(self) + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + self.close() + + def __del__(self): + try: + self.close(timeout=0.1) + except Exception: + pass + + def _repr_html_(self): + """Allow Jupyter to display this object directly.""" + if self._closed: + return "Static viewer closed.
" + 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 + + if port is None: + port = _find_free_port() + + # Start the server without opening a browser + kwargs["open_browser"] = False + kwargs["autoclose"] = False + kwargs["display_url"] = False + server = view.show(data, port=port, **kwargs) + + host = os.environ.get("CORTEX_JUPYTER_IFRAME_HOST", "127.0.0.1") + url = "http://%s:%d/mixer.html" % (host, 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, output_dir=None, **kwargs): + """Display brain data using a static WebGL viewer inline. + + Uses ``cortex.webgl.make_static`` to generate a *directory* containing + ``index.html`` plus all required JS/CSS/data assets, then embeds it in + the notebook inside an IFrame. + + 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. + output_dir : str or None, optional + Directory to write the static viewer into. When provided, the IFrame + uses a relative path (no HTTP server is started) and ``close()`` will + **not** delete the directory. This is useful for embedding in rendered + documentation or for persisting the viewer files. + When ``None`` (default), a temporary directory is created, served via + a local HTTP server, and cleaned up on ``close()``. + **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 (if any) and clean up temporary files. + """ + from . import view + + # Format width + if isinstance(width, int): + width_str = "%dpx" % width + else: + width_str = width + + if output_dir is not None: + # Persistent mode: write to the given directory, use relative IFrame + try: + view.make_static(output_dir, data, html_embed=True, **kwargs) + except Exception as e: + raise RuntimeError( + "Failed to generate static viewer. " + "Check that data is valid and cortex is properly configured." + ) from e + + index_html = os.path.join(output_dir, "index.html") + if not os.path.isfile(index_html): + raise FileNotFoundError( + "make_static() did not produce index.html. " + "This may indicate a problem with the static template." + ) + + iframe = IFrame(src=index_html, width=width_str, height=height) + ipydisplay(iframe) + return StaticViewer(iframe, None, None, None) + + # Ephemeral mode: temp directory + HTTP server + 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." + ) + + 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") + + try: + httpd = http.server.HTTPServer((host, 0), _QuietHandler) + except OSError: + shutil.rmtree(tmpdir, ignore_errors=True) + raise + + port = httpd.server_address[1] + + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + + try: + iframe = IFrame( + src="http://%s:%d/index.html" % (host, port), + width=width_str, + height=height, + ) + ipydisplay(iframe) + except Exception: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=1.0) + shutil.rmtree(tmpdir, ignore_errors=True) + raise + + 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/docs/conf.py b/docs/conf.py index d2031fe7..e8dfb1be 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,57 +18,92 @@ # 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"] + +try: + import nbsphinx # noqa: F401 + + extensions.append("nbsphinx") + nbsphinx_execute = "auto" +except ImportError: + exclude_patterns.append("notebooks/*.ipynb") 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\u2013%d, Gallant Lab' % datetime.now().year +project = "pycortex" +copyright = "2012\u2013%d, Gallant Lab" % datetime.now().year # 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 full version, including alpha/beta/rc tags. # Relies on setuptools-scm generating cortex/_version.py at install time. release = cortex.__version__ @@ -77,23 +112,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::). @@ -101,161 +137,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 # "