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 # " 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 ------------------------------------------------ @@ -264,59 +294,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'Gallant Lab' -epub_publisher = u'Gallant Lab' -epub_copyright = u'2012\u2013%d, Gallant Lab' % datetime.now().year +epub_title = "pycortex" +epub_author = "Gallant Lab" +epub_publisher = "Gallant Lab" +epub_copyright = "2012\u2013%d, Gallant Lab" % datetime.now().year # 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/index.rst b/docs/index.rst index 1c4d421a..66edd10f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,9 +30,14 @@ Example Gallery --------------- .. toctree:: :maxdepth: 3 - + auto_examples/index +.. toctree:: + :maxdepth: 1 + + notebooks/index + API Reference ------------- .. toctree:: diff --git a/docs/notebooks/.gitignore b/docs/notebooks/.gitignore new file mode 100644 index 00000000..2fcc2d40 --- /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/headless_panels.ipynb b/docs/notebooks/headless_panels.ipynb new file mode 100644 index 00000000..c27c5c4b --- /dev/null +++ b/docs/notebooks/headless_panels.ipynb @@ -0,0 +1,72 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# Making 3D Brain Views without Opening a Browser\n\nPycortex can render 3D brain views without opening a browser using\n`cortex.export.plot_panels` with `headless=True`. This uses\nPlaywright to launch a headless Chromium browser that renders\nthe WebGL scene.\n\n## Prerequisites\n\nInstall Playwright and download the bundled Chromium binary:\n\n```bash\npip install playwright\nplaywright install chromium\n```" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Composite view with all panels\n", + "\n", + "The `params_flatmap_inflated_lateral_medial_ventral` preset\n", + "renders a flatmap with lateral, medial, and ventral insets —\n", + "all in a single figure." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import cortex\n", + "import cortex.export\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "np.random.seed(1234)\n", + "volume = cortex.Volume.random(subject=\"S1\", xfmname=\"fullhead\")\n", + "\n", + "fig = cortex.export.plot_panels(\n", + " volume,\n", + " headless=True,\n", + " **cortex.export.params_flatmap_inflated_lateral_medial_ventral,\n", + ")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Other predefined layouts\n", + "\n", + "Pycortex ships with several ready-made panel configurations.\n", + "Here are all the available presets:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "predefined = {\n name: getattr(cortex.export, name)\n for name in sorted(dir(cortex.export))\n if name.startswith(\"params_\")\n}\n\nfor name, params in predefined.items():\n fig = cortex.export.plot_panels(volume, headless=True, **params)\n w, h = fig.get_size_inches()\n fig.suptitle(name, fontsize=14, fontweight=\"bold\", y=1.02)\n fig.subplots_adjust(top=0.95)\n plt.show()" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.9" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/docs/notebooks/index.rst b/docs/notebooks/index.rst new file mode 100644 index 00000000..c35fcbee --- /dev/null +++ b/docs/notebooks/index.rst @@ -0,0 +1,11 @@ +Jupyter Notebook Examples +========================= + +Interactive examples using pycortex in Jupyter notebooks. + +.. toctree:: + :maxdepth: 1 + :class: nbsphinx-gallery + + jupyter_notebook + headless_panels diff --git a/docs/notebooks/jupyter_notebook.ipynb b/docs/notebooks/jupyter_notebook.ipynb new file mode 100644 index 00000000..c145d295 --- /dev/null +++ b/docs/notebooks/jupyter_notebook.ipynb @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# WebGL Viewer in Jupyter Notebooks\n\nPycortex provides `cortex.webgl.jupyter` for embedding the interactive 3D\nbrain viewer directly in Jupyter notebook cells.\n\nThere are two display methods:\n\n| Method | How it works | Best for |\n|--------|-------------|----------|\n| **IFrame** (default) | Starts a Tornado server and embeds it in an IFrame | Live sessions with full interactivity (WebSocket control, surface morphing) |\n| **Static** | Generates a self-contained viewer directory served by a lightweight HTTP server | Sharing notebooks, documentation, quick previews |" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Display a single volume\n\nThe simplest way to show brain data is `cortex.webgl.jupyter.display()`.\nPass any pycortex data object (Volume, Vertex, Dataset) and it embeds\nthe viewer inline.\n\nIn a live Jupyter session you can use the default IFrame mode:\n\n```python\nviewer = cortex.webgl.jupyter.display(volume)\n```\n\nOr use the static method, which writes viewer files to a directory\nand serves them via a lightweight HTTP server:\n\n```python\nviewer = cortex.webgl.jupyter.display(volume, method=\"static\")\n```\n\nYou can also pass ``output_dir`` to persist the viewer files to a\nspecific directory (useful for documentation or sharing):" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "import cortex\nimport numpy as np\n\nnp.random.seed(1234)\nvolume = cortex.Volume.random(subject=\"S1\", xfmname=\"fullhead\")\n\nviewer = cortex.webgl.jupyter.display(\n volume, method=\"static\", output_dir=\"static_viewer\"\n)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Click and drag the brain to rotate, scroll to zoom. The toolbar on\n", + "the right lets you toggle overlays, adjust the colormap, and switch\n", + "surface types.\n", + "\n", + "## Multiple datasets\n", + "\n", + "Pass a ``cortex.Dataset`` to display multiple data layers with a\n", + "dropdown to switch between them:\n", + "\n", + "```python\n", + "ds = cortex.Dataset(\n", + " random_vol_1=volume,\n", + " random_vol_2=cortex.Volume.random(\"S1\", \"fullhead\"),\n", + ")\n", + "viewer = cortex.webgl.jupyter.display(ds)\n", + "```\n", + "\n", + "## Context manager\n", + "\n", + "Use the ``with`` statement to ensure cleanup happens automatically:\n", + "\n", + "```python\n", + "with cortex.webgl.jupyter.display(volume, method=\"static\") as viewer:\n", + " pass # viewer is displayed; do other work here\n", + "# Server and temp files are cleaned up on exit\n", + "```\n", + "\n", + "## Programmatic control (IFrame mode)\n", + "\n", + "In IFrame mode, you can control the viewer programmatically via\n", + "WebSocket. Always call ``get_client()`` in a **separate cell** from\n", + "``display()`` — it blocks until the browser connects.\n", + "\n", + "```python\n", + "# Cell 1: start the viewer\n", + "server = cortex.webgl.jupyter.display(volume)\n", + "```\n", + "\n", + "```python\n", + "# Cell 2: 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(\"screenshot.png\")\n", + "```\n", + "\n", + "## Closing viewers\n", + "\n", + "Close a single viewer:\n", + "\n", + "```python\n", + "viewer.close()\n", + "```\n", + "\n", + "Or shut down all active static viewers at once:\n", + "\n", + "```python\n", + "cortex.webgl.jupyter.close_all()\n", + "```\n", + "\n", + "## Remote Jupyter setups\n", + "\n", + "If running Jupyter on a remote server (JupyterHub, SSH tunnel,\n", + "cloud VM), set environment variables so IFrame URLs resolve correctly:\n", + "\n", + "```bash\n", + "# For IFrame/Tornado mode:\n", + "export CORTEX_JUPYTER_IFRAME_HOST=my-server.example.com\n", + "\n", + "# For static mode:\n", + "export CORTEX_JUPYTER_STATIC_HOST=0.0.0.0\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "pycortex (docs)", + "language": "python", + "name": "pycortex-docs" + }, + "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.10.9" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e65c0461..83c3eb06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,15 @@ test = [ "pytest", "pytest-cov", ] +docs = [ + "sphinx", + "numpydoc", + "sphinx-gallery", + "alabaster", + "nbsphinx", + "pandoc", + "ipykernel", +] [project.optional-dependencies] headless = [