Skip to content

Commit 14df6ba

Browse files
fangqclaude
andcommitted
[fix] reliably suppress plot handle echo in notebooks, add plotly scrollzoom control, bump 0.6.2
- Replace fragile bytecode nargout for notebooks with a PlotHandle dict subclass that defines _ipython_display_, so a bare plotmesh(...) no longer echoes its handle dict in Jupyter/Colab on any Python version (the old PRINT_EXPR check missed Python 3.12's CALL_INTRINSIC_1; that opcode is now also handled for the plain-REPL fallback). PlotHandle still behaves as a dict (indexing, print, repr) and assignment returns it as before. - plotly: disable wheel scrollZoom by default so Colab's short output frame can scroll; re-enable with scrollzoom=True, or pass a full config=. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d228569 commit 14df6ba

4 files changed

Lines changed: 78 additions & 27 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
* **Copyright**: (C) Qianqian Fang (2024-2025) <q.fang at neu.edu>
66
* **License**: GNU Public License V3 or later
7-
* **Version**: 0.6.1
7+
* **Version**: 0.6.2
88
* **URL**: [https://pypi.org/project/iso2mesh/](https://pypi.org/project/iso2mesh/)
99
* **Homepage**: [https://iso2mesh.sf.net](https://iso2mesh.sf.net)
1010
* **Github**: [https://github.com/NeuroJSON/pyiso2mesh](https://github.com/NeuroJSON/pyiso2mesh)

iso2mesh/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@
264264
ndimfilter,
265265
)
266266

267-
__version__ = "0.6.1"
267+
__version__ = "0.6.2"
268268
__all__ = [
269269
"advancefront",
270270
"barycentricgrid",

iso2mesh/plot.py

Lines changed: 75 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -61,47 +61,73 @@
6161
COLOR_OFFSET = 3
6262

6363
##====================================================================================
64-
## MATLAB-style nargout emulation
64+
## handle dict that does not echo in Jupyter / IPython
6565
##====================================================================================
6666

6767

68+
class PlotHandle(dict):
69+
"""
70+
A dict of plot handles (keys ``'fig'``, ``'ax'``, ``'obj'``) returned by the
71+
plotting functions. It behaves exactly like a normal dict -- indexing,
72+
``print()`` and ``repr()`` all work -- but declares an empty rich display so
73+
that a bare ``plotmesh(...)`` as the last line of a Jupyter/Colab/IPython
74+
cell does not echo the (often huge) handle dict. Capture it (``h =
75+
plotmesh(...)``) to use the handles as before.
76+
77+
This mechanism is environment-independent (unlike bytecode/``nargout``
78+
inspection) and works across notebook frontends and Python versions.
79+
"""
80+
81+
def _ipython_display_(self):
82+
# Defining this method makes IPython treat display as "handled"; by
83+
# doing nothing we suppress the automatic Out[...] echo entirely, while
84+
# repr()/print() and dict access keep working normally.
85+
pass
86+
87+
6888
def _return_value_used():
6989
"""
70-
Best-effort emulation of MATLAB's ``nargout``: inspect the caller's bytecode
71-
at the call site and report whether the returned value is actually used.
90+
Best-effort emulation of MATLAB's ``nargout`` for *non-notebook* callers:
91+
inspect the caller's bytecode and report whether the return value is used.
7292
7393
Returns False when the result is discarded -- a bare ``plotmesh(...)``
74-
statement in a script (``POP_TOP``) or auto-displayed as the last expression
75-
in a Jupyter/IPython cell or REPL (``PRINT_EXPR``) -- so the caller can avoid
76-
returning (and thereby echoing) the handle dict. Returns True otherwise, or
77-
if the call site cannot be inspected, preserving the documented return value.
94+
statement (``POP_TOP``) or auto-displayed as the last expression in a plain
95+
Python REPL (``PRINT_EXPR`` on <=3.11, ``CALL_INTRINSIC_1``/print on 3.12+).
96+
Returns True otherwise, or if the call site cannot be inspected. In notebooks
97+
the suppression is handled robustly by :class:`PlotHandle` instead, so a
98+
misfire here is harmless.
7899
"""
79100
try:
80101
frame = sys._getframe(2) # 0: this fn, 1: decorated wrapper, 2: caller
81102
lasti = frame.f_lasti
82103
nxt = next(
83-
(
84-
ins.opname
85-
for ins in dis.get_instructions(frame.f_code)
86-
if ins.offset > lasti
87-
),
104+
(ins for ins in dis.get_instructions(frame.f_code) if ins.offset > lasti),
88105
None,
89106
)
90-
return nxt not in ("POP_TOP", "PRINT_EXPR")
107+
if nxt is None:
108+
return True
109+
if nxt.opname in ("POP_TOP", "PRINT_EXPR"):
110+
return False
111+
# Python 3.12 replaced PRINT_EXPR with CALL_INTRINSIC_1(INTRINSIC_PRINT)
112+
if nxt.opname == "CALL_INTRINSIC_1" and "PRINT" in str(nxt.argval):
113+
return False
114+
return True
91115
except Exception:
92116
return True
93117

94118

95119
def _nargout_aware(func):
96120
"""
97-
Decorator that returns ``None`` (instead of the handle dict) when the wrapped
98-
plotting function's return value is discarded by the caller, so an
99-
interactive ``plotmesh(...)`` does not print its handle dict.
121+
Decorator for the plotting functions. Wraps the returned handle dict in a
122+
:class:`PlotHandle` (so it never echoes in notebooks) and, for plain
123+
scripts/REPLs, returns ``None`` when the caller discards the value.
100124
"""
101125

102126
@functools.wraps(func)
103127
def wrapper(*args, **kwargs):
104128
result = func(*args, **kwargs)
129+
if isinstance(result, dict) and not isinstance(result, PlotHandle):
130+
result = PlotHandle(result)
105131
return result if _return_value_used() else None
106132

107133
return wrapper
@@ -778,6 +804,11 @@ def _plot_options(kwargs, default_cmap):
778804
"show_edges": kwargs.pop("show_edges", True),
779805
"edgecolor": kwargs.pop("edgecolor", kwargs.pop("edgecolors", "black")),
780806
"jupyter_backend": kwargs.pop("jupyter_backend", None),
807+
# plotly: disable wheel-zoom by default so the page/output can scroll
808+
# (esp. in Colab's short output frame); zoom stays available via the
809+
# modebar zoom button, or set scrollzoom=True to re-enable wheel zoom.
810+
"scrollzoom": kwargs.pop("scrollzoom", False),
811+
"config": kwargs.pop("config", None),
781812
"show": kwargs.pop("show", not hold),
782813
}
783814

@@ -940,7 +971,12 @@ def finalize(self, fig, cfg):
940971
margin=dict(l=0, r=0, t=0, b=0),
941972
)
942973
if cfg["show"]:
943-
fig.show()
974+
# scrollZoom off by default lets the notebook/page scroll instead of
975+
# the wheel zooming the plot; zoom via the modebar zoom button or
976+
# pass scrollzoom=True. A user-supplied config takes precedence.
977+
config = dict(cfg.get("config") or {})
978+
config.setdefault("scrollZoom", bool(cfg["scrollzoom"]))
979+
fig.show(config=config)
944980

945981
@staticmethod
946982
def _add(fig, h, trace):
@@ -1038,13 +1074,28 @@ def finalize(self, pl, cfg):
10381074
if not cfg["show"]:
10391075
return
10401076
# In a Jupyter notebook pyvista renders a *static* image unless an
1041-
# interactive backend (e.g. 'trame') is active; forward jupyter_backend
1042-
# so users can request live rotation/zoom. Outside notebooks it opens an
1043-
# interactive native window as usual.
1044-
kw = {}
1045-
if cfg.get("jupyter_backend"):
1046-
kw["jupyter_backend"] = cfg["jupyter_backend"]
1047-
pl.show(**kw)
1077+
# interactive backend is active; forward jupyter_backend so users can
1078+
# request live rotation/zoom. Outside notebooks it opens an interactive
1079+
# native window as usual. Google Colab cannot use the websocket-based
1080+
# 'trame' server backend, so default to the self-contained client-side
1081+
# 'html' backend there, which is rotatable without a server.
1082+
jb = cfg.get("jupyter_backend")
1083+
if jb is None and "google.colab" in sys.modules:
1084+
jb = "html"
1085+
try:
1086+
pl.show(jupyter_backend=jb) if jb else pl.show()
1087+
except Exception as e:
1088+
import warnings
1089+
1090+
warnings.warn(
1091+
f"pyvista interactive backend '{jb}' failed ({e}); falling back "
1092+
"to the default. For rotatable plots in Google Colab, run once:\n"
1093+
" !apt-get -qq install xvfb libgl1-mesa-glx\n"
1094+
" import pyvista as pv; pv.start_xvfb()\n"
1095+
" pv.set_jupyter_backend('html')\n"
1096+
"or use backend='plotly', which is interactive out of the box."
1097+
)
1098+
pl.show()
10481099

10491100

10501101
def _plotmesh_pyvista(node, *args, **kwargs):

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
setup(
77
name="iso2mesh",
88
packages=["iso2mesh"],
9-
version="0.6.1",
9+
version="0.6.2",
1010
license="GPLv3+",
1111
description="One-liner 3D Surface and Tetrahedral Mesh Generation Toolbox",
1212
long_description=readme,

0 commit comments

Comments
 (0)