Skip to content

Commit 3a32a4b

Browse files
petercorkeclaude
andauthored
feat(base): add environment() and move isnotebook out of the matplotlib block (#271)
## Summary `isnotebook()` lived inside `graphics.py`'s matplotlib `try:` block, so it disappeared when matplotlib was not installed, and it returned `False` on Google Colab and JupyterLite. RTB, MVTB and bdsim have each grown their own copy of the check as a result. - New matplotlib-free module `spatialmath/base/_environment.py`. - `environment()` returns one of `"terminal"`, `"ipython"`, `"jupyter"`, `"colab"`, `"jupyterlite"`. - `isnotebook()` is now a thin wrapper, `True` for `jupyter`, `colab` and `jupyterlite`. - `smb.isnotebook` is still exported, and `smb.environment` is new. Nothing in SMTB called `isnotebook()`. JupyterLab, classic Notebook, VS Code and Jupyter Desktop all report `"jupyter"`: they cannot be told apart reliably from inside the kernel, and how figures render depends on the matplotlib backend rather than the front end. The docstring says so. ## Behaviour change `isnotebook()` now returns `True` on Colab and JupyterLite (previously `False`). ## Testing Full suite: 416 passed, 3 skipped (was 413 on current master). Black 23.10.0 clean on the touched files. New `tests/base/test_environment.py` covers all five values plus an unknown shell. **These tests are mocked only**: a fake `get_ipython()` and patched `sys.modules` / `sys.platform` stand in for the real front ends. No real Jupyter, Colab or JupyterLite kernel is exercised in CI. ## Follow-ups (not in this PR) RTB and MVTB can drop their private `_isnotebook` copies in favour of this once it lands. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 0d243b3 commit 3a32a4b

4 files changed

Lines changed: 144 additions & 21 deletions

File tree

‎spatialmath/base/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from spatialmath.base.vectors import * # lgtm [py/polluting-import]
1111
from spatialmath.base.symbolic import * # lgtm [py/polluting-import]
1212
from spatialmath.base.numeric import * # lgtm [py/polluting-import]
13+
from spatialmath.base._environment import * # lgtm [py/polluting-import]
1314

1415
import importlib
1516

@@ -46,7 +47,6 @@
4647
"plotvol2": "graphics",
4748
"plotvol3": "graphics",
4849
"expand_dims": "graphics",
49-
"isnotebook": "graphics",
5050
}
5151

5252

@@ -394,6 +394,7 @@ def __getattr__(name):
394394
"axes_logic",
395395
"set_window_title",
396396
"expand_dims",
397+
"environment",
397398
"isnotebook",
398399
# spatial.base.numeric
399400
"numjac",

‎spatialmath/base/_environment.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Part of Spatial Math Toolbox for Python
2+
# Copyright (c) 2000 Peter Corke
3+
# MIT Licence, see details in top-level file: LICENCE
4+
5+
"""
6+
Detect the interactive environment that Python is running in.
7+
8+
This module deliberately does not import Matplotlib, so it is cheap to import
9+
and works when Matplotlib is not installed.
10+
"""
11+
12+
import sys
13+
from typing import Literal
14+
15+
__all__ = ["environment", "isnotebook"]
16+
17+
Environment = Literal["terminal", "ipython", "jupyter", "colab", "jupyterlite"]
18+
19+
20+
def environment() -> Environment:
21+
"""
22+
Determine the environment that Python is running in
23+
24+
:return: the environment
25+
:rtype: str
26+
27+
The result is one of:
28+
29+
======================== ====================================================
30+
``"terminal"`` standard Python interpreter, or script
31+
``"ipython"`` IPython running in a terminal
32+
``"jupyter"`` Jupyter kernel, eg. JupyterLab, Notebook, VS Code or
33+
qtconsole
34+
``"colab"`` Google Colab
35+
``"jupyterlite"`` JupyterLite, running in the browser (Pyodide)
36+
======================== ====================================================
37+
38+
.. note:: The different Jupyter front ends (JupyterLab, classic Notebook,
39+
VS Code, Jupyter Desktop) cannot be reliably told apart from within the
40+
kernel, so all are reported as ``"jupyter"``. How figures will be
41+
rendered depends on the Matplotlib backend, not on the front end.
42+
43+
:seealso: :func:`isnotebook`
44+
"""
45+
# Colab and JupyterLite both also provide an IPython kernel, so test for
46+
# them first
47+
if "google.colab" in sys.modules:
48+
return "colab"
49+
if sys.platform == "emscripten":
50+
return "jupyterlite"
51+
52+
try:
53+
shell = get_ipython().__class__.__name__ # type: ignore[name-defined]
54+
except NameError:
55+
return "terminal" # probably standard Python interpreter
56+
57+
if shell == "ZMQInteractiveShell":
58+
return "jupyter" # Jupyter notebook or qtconsole
59+
elif shell == "TerminalInteractiveShell":
60+
return "ipython"
61+
else:
62+
return "terminal" # other type (?)
63+
64+
65+
def isnotebook() -> bool:
66+
"""
67+
Determine if code is being run from a Jupyter notebook
68+
69+
:return: True if running in a Jupyter kernel, Google Colab or JupyterLite
70+
:rtype: bool
71+
72+
:seealso: :func:`environment`
73+
74+
:references:
75+
76+
- https://stackoverflow.com/questions/15411967/how-can-i-check-if-code-is-executed-in-the-ipython-notebook/39662359#39662359
77+
"""
78+
return environment() in ("jupyter", "colab", "jupyterlite")

‎spatialmath/base/graphics.py‎

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1838,26 +1838,6 @@ def set_window_title(fig: plt.Figure, title: str) -> bool:
18381838
return True
18391839
return False
18401840

1841-
def isnotebook() -> bool:
1842-
"""
1843-
Determine if code is being run from a Jupyter notebook
1844-
1845-
:references:
1846-
1847-
- https://stackoverflow.com/questions/15411967/how-can-i-check-if-code-
1848-
is-executed-in-the-ipython-notebook/39662359#39662359
1849-
"""
1850-
try:
1851-
shell = get_ipython().__class__.__name__
1852-
if shell == "ZMQInteractiveShell":
1853-
return True # Jupyter notebook or qtconsole
1854-
elif shell == "TerminalInteractiveShell":
1855-
return False # Terminal running IPython
1856-
else:
1857-
return False # Other type (?)
1858-
except NameError:
1859-
return False # Probably standard Python interpreter
1860-
18611841
if __name__ == "__main__":
18621842
import pathlib
18631843

‎tests/base/test_environment.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import builtins
2+
import sys
3+
4+
import pytest
5+
6+
from spatialmath.base import environment, isnotebook
7+
8+
# These tests are mocked: a fake get_ipython() and sys.modules / sys.platform
9+
# stand in for the real front ends, which cannot be run under CI.
10+
11+
12+
def fake_shell(name):
13+
shell = type(name, (), {})()
14+
return lambda: shell
15+
16+
17+
@pytest.fixture
18+
def plain(monkeypatch):
19+
"""Standard interpreter: no IPython, not Colab, not Pyodide"""
20+
monkeypatch.delattr(builtins, "get_ipython", raising=False)
21+
monkeypatch.delitem(sys.modules, "google.colab", raising=False)
22+
monkeypatch.setattr(sys, "platform", "linux")
23+
return monkeypatch
24+
25+
26+
def test_terminal(plain):
27+
assert environment() == "terminal"
28+
assert not isnotebook()
29+
30+
31+
def test_ipython_terminal(plain):
32+
plain.setattr(
33+
builtins, "get_ipython", fake_shell("TerminalInteractiveShell"), raising=False
34+
)
35+
assert environment() == "ipython"
36+
assert not isnotebook()
37+
38+
39+
def test_jupyter(plain):
40+
plain.setattr(
41+
builtins, "get_ipython", fake_shell("ZMQInteractiveShell"), raising=False
42+
)
43+
assert environment() == "jupyter"
44+
assert isnotebook()
45+
46+
47+
def test_unknown_shell(plain):
48+
plain.setattr(builtins, "get_ipython", fake_shell("SomethingElse"), raising=False)
49+
assert environment() == "terminal"
50+
assert not isnotebook()
51+
52+
53+
def test_colab(plain):
54+
# Colab's shell is not a ZMQInteractiveShell, so detect it by the module
55+
plain.setattr(builtins, "get_ipython", fake_shell("Shell"), raising=False)
56+
plain.setitem(sys.modules, "google.colab", object())
57+
assert environment() == "colab"
58+
assert isnotebook()
59+
60+
61+
def test_jupyterlite(plain):
62+
plain.setattr(sys, "platform", "emscripten")
63+
assert environment() == "jupyterlite"
64+
assert isnotebook()

0 commit comments

Comments
 (0)