From d1556fd0e971c12c3200076a52a347c19c0dc73f Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Thu, 22 Oct 2026 11:53:13 +1000 Subject: [PATCH] feat(base): add environment() and move isnotebook out of the matplotlib block isnotebook() lived inside graphics.py's matplotlib try-block, so it vanished when matplotlib was missing, and it reported False for Google Colab and JupyterLite. RTB, MVTB and bdsim each grew their own copy as a result. Add environment(), returning "terminal", "ipython", "jupyter", "colab" or "jupyterlite", in a new matplotlib-free module, and make isnotebook() a thin wrapper that is True for jupyter, colab and jupyterlite. smb.isnotebook is still exported. Tests use a faked get_ipython/sys.modules/sys.platform. Co-Authored-By: Claude Sonnet 5.5 --- spatialmath/base/__init__.py | 3 +- spatialmath/base/_environment.py | 78 ++++++++++++++++++++++++++++++++ spatialmath/base/graphics.py | 20 -------- tests/base/test_environment.py | 64 ++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 spatialmath/base/_environment.py create mode 100644 tests/base/test_environment.py diff --git a/spatialmath/base/__init__.py b/spatialmath/base/__init__.py index b01e14ba..75c2f501 100644 --- a/spatialmath/base/__init__.py +++ b/spatialmath/base/__init__.py @@ -10,6 +10,7 @@ from spatialmath.base.vectors import * # lgtm [py/polluting-import] from spatialmath.base.symbolic import * # lgtm [py/polluting-import] from spatialmath.base.numeric import * # lgtm [py/polluting-import] +from spatialmath.base._environment import * # lgtm [py/polluting-import] import importlib @@ -45,7 +46,6 @@ "plotvol2": "graphics", "plotvol3": "graphics", "expand_dims": "graphics", - "isnotebook": "graphics", } @@ -392,6 +392,7 @@ def __getattr__(name): "plot_cuboid", "axes_logic", "expand_dims", + "environment", "isnotebook", # spatial.base.numeric "numjac", diff --git a/spatialmath/base/_environment.py b/spatialmath/base/_environment.py new file mode 100644 index 00000000..7d5b017e --- /dev/null +++ b/spatialmath/base/_environment.py @@ -0,0 +1,78 @@ +# Part of Spatial Math Toolbox for Python +# Copyright (c) 2000 Peter Corke +# MIT Licence, see details in top-level file: LICENCE + +""" +Detect the interactive environment that Python is running in. + +This module deliberately does not import Matplotlib, so it is cheap to import +and works when Matplotlib is not installed. +""" + +import sys +from typing import Literal + +__all__ = ["environment", "isnotebook"] + +Environment = Literal["terminal", "ipython", "jupyter", "colab", "jupyterlite"] + + +def environment() -> Environment: + """ + Determine the environment that Python is running in + + :return: the environment + :rtype: str + + The result is one of: + + ======================== ==================================================== + ``"terminal"`` standard Python interpreter, or script + ``"ipython"`` IPython running in a terminal + ``"jupyter"`` Jupyter kernel, eg. JupyterLab, Notebook, VS Code or + qtconsole + ``"colab"`` Google Colab + ``"jupyterlite"`` JupyterLite, running in the browser (Pyodide) + ======================== ==================================================== + + .. note:: The different Jupyter front ends (JupyterLab, classic Notebook, + VS Code, Jupyter Desktop) cannot be reliably told apart from within the + kernel, so all are reported as ``"jupyter"``. How figures will be + rendered depends on the Matplotlib backend, not on the front end. + + :seealso: :func:`isnotebook` + """ + # Colab and JupyterLite both also provide an IPython kernel, so test for + # them first + if "google.colab" in sys.modules: + return "colab" + if sys.platform == "emscripten": + return "jupyterlite" + + try: + shell = get_ipython().__class__.__name__ # type: ignore[name-defined] + except NameError: + return "terminal" # probably standard Python interpreter + + if shell == "ZMQInteractiveShell": + return "jupyter" # Jupyter notebook or qtconsole + elif shell == "TerminalInteractiveShell": + return "ipython" + else: + return "terminal" # other type (?) + + +def isnotebook() -> bool: + """ + Determine if code is being run from a Jupyter notebook + + :return: True if running in a Jupyter kernel, Google Colab or JupyterLite + :rtype: bool + + :seealso: :func:`environment` + + :references: + + - https://stackoverflow.com/questions/15411967/how-can-i-check-if-code-is-executed-in-the-ipython-notebook/39662359#39662359 + """ + return environment() in ("jupyter", "colab", "jupyterlite") diff --git a/spatialmath/base/graphics.py b/spatialmath/base/graphics.py index 40503c41..0928078c 100644 --- a/spatialmath/base/graphics.py +++ b/spatialmath/base/graphics.py @@ -1783,26 +1783,6 @@ def expand_dims(dim: ArrayLike = None, nd: int = 2) -> NDArray: else: raise ValueError("nd is 2 or 3") - def isnotebook() -> bool: - """ - Determine if code is being run from a Jupyter notebook - - :references: - - - https://stackoverflow.com/questions/15411967/how-can-i-check-if-code- - is-executed-in-the-ipython-notebook/39662359#39662359 - """ - try: - shell = get_ipython().__class__.__name__ - 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 - if __name__ == "__main__": import pathlib diff --git a/tests/base/test_environment.py b/tests/base/test_environment.py new file mode 100644 index 00000000..a9911b08 --- /dev/null +++ b/tests/base/test_environment.py @@ -0,0 +1,64 @@ +import builtins +import sys + +import pytest + +from spatialmath.base import environment, isnotebook + +# These tests are mocked: a fake get_ipython() and sys.modules / sys.platform +# stand in for the real front ends, which cannot be run under CI. + + +def fake_shell(name): + shell = type(name, (), {})() + return lambda: shell + + +@pytest.fixture +def plain(monkeypatch): + """Standard interpreter: no IPython, not Colab, not Pyodide""" + monkeypatch.delattr(builtins, "get_ipython", raising=False) + monkeypatch.delitem(sys.modules, "google.colab", raising=False) + monkeypatch.setattr(sys, "platform", "linux") + return monkeypatch + + +def test_terminal(plain): + assert environment() == "terminal" + assert not isnotebook() + + +def test_ipython_terminal(plain): + plain.setattr( + builtins, "get_ipython", fake_shell("TerminalInteractiveShell"), raising=False + ) + assert environment() == "ipython" + assert not isnotebook() + + +def test_jupyter(plain): + plain.setattr( + builtins, "get_ipython", fake_shell("ZMQInteractiveShell"), raising=False + ) + assert environment() == "jupyter" + assert isnotebook() + + +def test_unknown_shell(plain): + plain.setattr(builtins, "get_ipython", fake_shell("SomethingElse"), raising=False) + assert environment() == "terminal" + assert not isnotebook() + + +def test_colab(plain): + # Colab's shell is not a ZMQInteractiveShell, so detect it by the module + plain.setattr(builtins, "get_ipython", fake_shell("Shell"), raising=False) + plain.setitem(sys.modules, "google.colab", object()) + assert environment() == "colab" + assert isnotebook() + + +def test_jupyterlite(plain): + plain.setattr(sys, "platform", "emscripten") + assert environment() == "jupyterlite" + assert isnotebook()