From c4c462af7adf1c697ec9e3c80f39585277d99cc2 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Sun, 1 Nov 2026 16:12:54 +1000 Subject: [PATCH] refactor: one isnotebook() for the whole package PyPlot.py and mobile/Vehicle.py each had their own notebook check, and they disagreed. Vehicle's looked up get_ipython in the module globals, where IPython does not put it, so its shell test never ran and it fell back on "ipykernel is loaded", true in a terminal IPython that imported it. Move the one in PyPlot.py, which handles JupyterLite, Colab and a Jupyter kernel, to tools/plot.py and use it from both. PyPlot.py and Vehicle.py still call it _isnotebook, so existing uses and tests that patch it are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- src/roboticstoolbox/backends/PyPlot/PyPlot.py | 36 +-------------- src/roboticstoolbox/mobile/Vehicle.py | 22 +--------- src/roboticstoolbox/tools/plot.py | 34 ++++++++++++++ tests/test_render_mode.py | 44 +++++++++++++++++++ 4 files changed, 80 insertions(+), 56 deletions(-) diff --git a/src/roboticstoolbox/backends/PyPlot/PyPlot.py b/src/roboticstoolbox/backends/PyPlot/PyPlot.py index 8aecfbca3..a5d31a92b 100644 --- a/src/roboticstoolbox/backends/PyPlot/PyPlot.py +++ b/src/roboticstoolbox/backends/PyPlot/PyPlot.py @@ -18,6 +18,7 @@ from roboticstoolbox.backends.Connector import Connector from roboticstoolbox.backends.PyPlot.EllipsePlot import EllipsePlot, ShapePlot from roboticstoolbox.backends.PyPlot.RobotPlot import RobotPlot +from roboticstoolbox.tools.plot import isnotebook as _isnotebook from roboticstoolbox.tools.plot import plane_view, set_window_title from roboticstoolbox.tools.types import ArrayLike @@ -848,41 +849,6 @@ def update(val: float, text: list[Any], robot: Any) -> None: # pragma: no cover self.step() -def _isnotebook() -> bool: - """ - Determine if code is being run from a Jupyter notebook or JupyterLite - - ``_isnotebook`` is True if running Jupyter notebook, JupyterLite, Google Colab, or - similar, else False - - :references: - - - https://stackoverflow.com/questions/15411967/how-can-i-check-if-code- - is-executed-in-the-ipython-notebook/39662359#39662359 - """ - import sys - - # Check if running in Pyodide/JupyterLite - if sys.platform == "emscripten": - return True - - # Colab's shell is google.colab._shell.Shell, not a ZMQInteractiveShell - if "google.colab" in sys.modules: - return True - - # Fall back to checking IPython shell type - try: - shell = get_ipython().__class__.__name__ # type: ignore[name-defined] - if shell == "ZMQInteractiveShell": - return True # Jupyter notebook or qtconsole - elif shell == "TerminalInteractiveShell": - return False # Terminal running IPython - else: - return False # Other type (?) - except NameError: - return False # Probably standard Python interpreter - - def _resolve_render_mode( render_mode: str | None = None, animation: bool = False ) -> str: diff --git a/src/roboticstoolbox/mobile/Vehicle.py b/src/roboticstoolbox/mobile/Vehicle.py index bde2d0fe5..0ea0550e4 100644 --- a/src/roboticstoolbox/mobile/Vehicle.py +++ b/src/roboticstoolbox/mobile/Vehicle.py @@ -27,6 +27,7 @@ from roboticstoolbox.mobile.Animations import VehicleAnimationBase, VehiclePolygon from roboticstoolbox.mobile.drivers import VehicleDriverBase from roboticstoolbox.mobile.workspace import WorkspaceLike, resolve_workspace +from roboticstoolbox.tools.plot import isnotebook as _isnotebook from roboticstoolbox.tools.plot import set_window_title from roboticstoolbox.tools.types import ArrayLike, NDArray @@ -36,27 +37,6 @@ ) -def _isnotebook() -> bool: - """ - Determine if code is being run from a Jupyter notebook or JupyterLite. - """ - if sys.platform == "emscripten": - return True - - if "ipykernel" in sys.modules or "google.colab" in sys.modules: - return True - - try: - get_ipython_fn = globals().get("get_ipython", None) - if callable(get_ipython_fn): - shell = get_ipython_fn().__class__.__name__ - if shell == "ZMQInteractiveShell": - return True - return False - except Exception: - return False - - def _refresh_animation_frame( fig: Figure | SubFigure | None, dt: float, pause: bool ) -> None: diff --git a/src/roboticstoolbox/tools/plot.py b/src/roboticstoolbox/tools/plot.py index 3a1e1ff5d..0a31fe695 100644 --- a/src/roboticstoolbox/tools/plot.py +++ b/src/roboticstoolbox/tools/plot.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from typing import Any import matplotlib.pyplot as plt @@ -232,3 +233,36 @@ def xplot( apply_block(block) return fig.get_axes() + + +def isnotebook() -> bool: + """ + Determine if code is being run from a Jupyter notebook or JupyterLite + + ``isnotebook`` is True if running Jupyter notebook, JupyterLite, Google Colab, or + similar, else False + + :references: + + - https://stackoverflow.com/questions/15411967/how-can-i-check-if-code- + is-executed-in-the-ipython-notebook/39662359#39662359 + """ + # Check if running in Pyodide/JupyterLite + if sys.platform == "emscripten": + return True + + # Colab's shell is google.colab._shell.Shell, not a ZMQInteractiveShell + if "google.colab" in sys.modules: + return True + + # Fall back to checking IPython shell type + try: + shell = get_ipython().__class__.__name__ # type: ignore[name-defined] + if shell == "ZMQInteractiveShell": + return True # Jupyter notebook or qtconsole + elif shell == "TerminalInteractiveShell": + return False # Terminal running IPython + else: + return False # Other type (?) + except NameError: + return False # Probably standard Python interpreter diff --git a/tests/test_render_mode.py b/tests/test_render_mode.py index 0db47a068..b8240137f 100644 --- a/tests/test_render_mode.py +++ b/tests/test_render_mode.py @@ -186,3 +186,47 @@ def test_colab_with_ipympl_is_a_widget_for_a_static_plot(monkeypatch): assert _resolve_render_mode(None) == "notebook-widget" assert _resolve_render_mode(None, animation=True) == "notebook-inline" + + +# --- one notebook check for the whole package --------------------------------- + + +def test_mobile_uses_the_same_notebook_check(): + from roboticstoolbox.tools.plot import isnotebook + + # the package also exports a class called Vehicle, so ask for the module + vehicle_module = sys.modules["roboticstoolbox.mobile.Vehicle"] + + assert pyplot_module._isnotebook is isnotebook + assert vehicle_module._isnotebook is isnotebook + + +def _shell(monkeypatch, name): + import builtins + + monkeypatch.delitem(sys.modules, "google.colab", raising=False) + monkeypatch.setattr(sys, "platform", "linux") + shell = type(name, (), {})() + monkeypatch.setattr(builtins, "get_ipython", lambda: shell, raising=False) + + +def test_a_jupyter_kernel_is_a_notebook(monkeypatch): + from roboticstoolbox.tools.plot import isnotebook + + _shell(monkeypatch, "ZMQInteractiveShell") + + assert isnotebook() is True + + +def test_ipython_in_a_terminal_is_not_a_notebook_even_if_ipykernel_is_loaded( + monkeypatch, +): + # the check the mobile module used to have counted a loaded ipykernel + import types + + from roboticstoolbox.tools.plot import isnotebook + + _shell(monkeypatch, "TerminalInteractiveShell") + monkeypatch.setitem(sys.modules, "ipykernel", types.ModuleType("ipykernel")) + + assert isnotebook() is False