"""Trame utilities for running in Jupyter.""" from __future__ import annotations import asyncio import logging import os from typing import TYPE_CHECKING from typing import Literal import warnings from trame.widgets import html as html_widgets from trame.widgets import vtk as vtk_widgets from trame.widgets import vuetify as vuetify2_widgets from trame.widgets import vuetify3 as vuetify3_widgets from typing_extensions import Concatenate try: from ipywidgets.widgets import HTML except ImportError: HTML = object import pyvista from pyvista.trame.ui import UI_TITLE from pyvista.trame.ui import get_viewer from pyvista.trame.views import CLOSED_PLOTTER_ERROR from pyvista.trame.views import get_server if TYPE_CHECKING: from collections.abc import Callable from IPython.display import IFrame from pyvista.jupyter import JupyterBackendOptions from pyvista.plotting.plotter import Plotter from pyvista.trame.ui.vuetify2 import Viewer SERVER_DOWN_MESSAGE = """Trame server has not launched. You must start the trame server before attempting to `show()` with PyVista. You can use the following snippet to launch the server: from pyvista.trame.jupyter import launch_server await launch_server('{name}').ready """ JUPYTER_SERVER_DOWN_MESSAGE = """Trame server has not launched. Prior to plotting, please make sure to run `set_jupyter_backend('trame')` when using the `'trame'`, `'server'`, or `'client'` Jupyter backends. import pyvista as pv pyvista.set_jupyter_backend('trame') If this issue persists, please open an issue in PyVista: https://github.com/pyvista/pyvista/issues """ logger = logging.getLogger(__name__) class TrameServerDownError(RuntimeError): # numpydoc ignore=PR01 """Exception when trame server is down for Jupyter.""" def __init__(self, server_name): """Call the base class constructor with the custom message.""" super().__init__(SERVER_DOWN_MESSAGE.format(name=server_name)) class TrameJupyterServerDownError(RuntimeError): """Exception when trame server is down for Jupyter.""" def __init__(self): """Call the base class constructor with the custom message.""" # Be incredibly verbose on how users should launch trame server # Both warn so it appears at top warnings.warn(JUPYTER_SERVER_DOWN_MESSAGE) # and Error super().__init__(JUPYTER_SERVER_DOWN_MESSAGE) class Widget(HTML): # type: ignore[misc] # numpydoc ignore=PR01 """Custom HTML iframe widget for trame viewer.""" def __init__(self, viewer, src, width=None, height=None, iframe_attrs=None, **kwargs): """Initialize.""" if HTML is object: msg = 'Please install `ipywidgets`.' raise ImportError(msg) # eventually we could maybe expose this, but for now make sure we're at least # consistent with matplotlib's color (light gray) if iframe_attrs is None: iframe_attrs = {} border = 'border: 1px solid rgb(221,221,221);' iframe_attrs = { **iframe_attrs, 'src': src, 'class': 'pyvista', 'style': f'width: {width}; height: {height}; {border}', } iframe_attrs_str = ' '.join(f'{key}="{value!s}"' for key, value in iframe_attrs.items()) value = f'' super().__init__(value, **kwargs) self._viewer = viewer self._src = src @property def viewer(self): # numpydoc ignore=RT01 """Get the associated viewer instance.""" return self._viewer @property def src(self): # numpydoc ignore=RT01 """Get the src URL.""" return self._src class EmbeddableWidget(HTML): # type: ignore[misc] # numpydoc ignore=PR01 """Custom HTML iframe widget for embedding the trame viewer.""" def __init__(self, plotter, width, height, **kwargs): """Initialize.""" if HTML is object: msg = 'Please install `ipywidgets`.' raise ImportError(msg) scene = plotter.export_html(filename=None) src = scene.getvalue().replace('"', '"') # eventually we could maybe expose this, but for now make sure we're at least # consistent with matplotlib's color (light gray) border = 'border: 1px solid rgb(221,221,221);' value = ( f'' ) super().__init__(value, **kwargs) self._src = src def launch_server(server=None, port=None, host=None, wslink_backend=None, **kwargs): """Launch a trame server for use with Jupyter. Parameters ---------- server : str, optional By default this uses :attr:`pyvista.global_theme.trame.jupyter_server_name `, which by default is set to ``'pyvista-jupyter'``. If a server name is given and such server is not available yet, it will be created otherwise the previously created instance will be returned. port : int, optional The port on which to bind the server. Defaults to 0 to automatically find an available port. host : str, optional The host name to bind the server to on launch. Server will bind to ``127.0.0.1`` by default unless user sets the environment variable ``TRAME_DEFAULT_HOST``. wslink_backend : str, optional The wslink backend that the server should use ``aiohttp`` by default, ``jupyter`` if the `trame_jupyter_extension `_ is used. **kwargs : dict, optional Any additional keyword arguments to pass to ``pyvista.trame.views.get_server``. Returns ------- trame_server.core.Server The launched Trame server. To ``await`` the launch, use the ``.ready`` future attribute on the server. """ if server is None: server = pyvista.global_theme.trame.jupyter_server_name if isinstance(server, str): server = get_server(server, **kwargs) if port is None: port = pyvista.global_theme.trame.jupyter_server_port if host is None: # Default to `127.0.0.1` unless user sets TRAME_DEFAULT_HOST host = os.environ.get('TRAME_DEFAULT_HOST', '127.0.0.1') if ( wslink_backend is None and pyvista.global_theme.trame.jupyter_extension_enabled ): # pragma: no cover wslink_backend = 'jupyter' # Must enable all used modules html_widgets.initialize(server) vtk_widgets.initialize(server) if server.client_type == 'vue2': vuetify2_widgets.initialize(server) else: vuetify3_widgets.initialize(server) def on_ready(**_): logger.debug(f'Server ready: {server}') if server._running_stage == 0: server.controller.on_server_ready.add(on_ready) server.start( exec_mode='task', host=host, port=port, open_browser=False, show_connection_info=False, disable_logging=True, timeout=0, backend=wslink_backend, ) # else, server is already running or launching return server def build_url( _server, ui=None, server_proxy_enabled=None, server_proxy_prefix=None, host='localhost', protocol='http', ): # numpydoc ignore=PR01,RT01 """Build the URL for the iframe.""" params = f'?ui={ui}&reconnect=auto' if ui else '?reconnect=auto' if server_proxy_enabled is None: server_proxy_enabled = pyvista.global_theme.trame.server_proxy_enabled if server_proxy_enabled: if server_proxy_prefix is None: server_proxy_prefix = pyvista.global_theme.trame.server_proxy_prefix # server_proxy_prefix assumes trailing slash prefix = server_proxy_prefix if server_proxy_prefix else '' src = f'{prefix}{_server.port}/index.html{params}' else: src = f'{protocol}://{host}:{_server.port}/index.html{params}' logger.debug(src) return src def initialize( server, plotter, mode=None, default_server_rendering=True, collapse_menu=False, **kwargs, ): # numpydoc ignore=PR01,RT01 """Generate the UI for a given plotter.""" state = server.state state.trame__title = UI_TITLE viewer = get_viewer( plotter, server=server, suppress_rendering=mode == 'client', ) with viewer.make_layout(server, template_name=plotter._id_name) as layout: viewer.layout = layout viewer.ui( mode=mode, default_server_rendering=default_server_rendering, collapse_menu=collapse_menu, **kwargs, ) return viewer def show_trame( plotter: Plotter, mode: JupyterBackendOptions | None = None, name: str | None = None, server_proxy_enabled: bool | None = None, server_proxy_prefix: str | None = None, jupyter_extension_enabled: bool | None = None, collapse_menu: bool = False, add_menu: bool = True, add_menu_items: Callable[[Literal['trame', 'server', 'client'], bool, bool], None] | None = None, default_server_rendering: bool = True, handler: Callable[Concatenate[Viewer, str, ...], IFrame] | None = None, **kwargs, ) -> EmbeddableWidget | IFrame | Widget: """Run and display the trame application in jupyter's event loop. Parameters ---------- plotter : pyvista.Plotter The PyVista plotter to show. mode : str, optional The UI view mode. This can be set on the global theme. Options are: * ``'trame'``: Uses a view that can switch between client and server rendering modes. * ``'server'``: Uses a view that is purely server rendering. * ``'client'``: Uses a view that is purely client rendering (generally safe without a virtual frame buffer) * ``'html'``: Exports the scene for client rendering that can be embedded in a webpage. name : str, optional The name of the trame server on which the UI is defined. server_proxy_enabled : bool, default: False Build a relative URL for use with ``jupyter-server-proxy``. server_proxy_prefix : str, optional URL prefix when using ``server_proxy_enabled``. This can be set globally in the theme. To ignore, pass ``False``. For use with ``jupyter-server-proxy``, often set to ``proxy/``. jupyter_extension_enabled : bool, default: False Build a relative URL for use with ``trame-jupyter-extension``. collapse_menu : bool, default: False Collapse the UI menu (camera controls, etc.) on start. add_menu : bool, default: True Add a UI controls VCard to the VContainer. add_menu_items : callable, default: None Append more UI controls to the VCard menu. Should be a function similar to Viewer.ui_controls(). default_server_rendering : bool, default: True Whether to use server-side or client-side rendering on-start when using the ``'trame'`` mode. handler : callable, optional Pass a callable that accptes the viewer instance, the string URL, and ``**kwargs`` to create custom HTML representations of the output. .. code-block:: python import pyvista as pv from IPython.display import IFrame mesh = pyvista.Wavelet() def handler(viewer, src, **kwargs): return IFrame(src, '75%', '500px') p = pyvista.Plotter(notebook=True) _ = p.add_mesh(mesh) iframe = p.show( jupyter_backend='trame', jupyter_kwargs=dict(handler=handler), return_viewer=True, ) iframe **kwargs : dict, optional Mostly ignored, though ``protocol`` and ``host`` can be use to override the iframe src url and ``height`` and ``width`` can be used to override the iframe style. Remaining kwargs are passed to ``ipywidgets.widgets.HTML``. Returns ------- ipywidgets.widgets.HTML or handler result Returns a HTML IFrame widget or the result of the passed handler. """ if plotter.render_window is None: raise RuntimeError(CLOSED_PLOTTER_ERROR) if plotter._window_size_unset: dw, dh = '99%', '600px' else: width, height = plotter.window_size dw = f'{width}px' dh = f'{height}px' kwargs.setdefault('width', dw) kwargs.setdefault('height', dh) if mode == 'html': return EmbeddableWidget(plotter, **kwargs) if jupyter_extension_enabled is None: jupyter_extension_enabled = pyvista.global_theme.trame.jupyter_extension_enabled if name is None: server = get_server(name=pyvista.global_theme.trame.jupyter_server_name) else: server = get_server(name=name) if name is None and not server.running: wslink_backend = 'aiohttp' if jupyter_extension_enabled: # pragma: no cover wslink_backend = 'jupyter' elegantly_launch(server, wslink_backend=wslink_backend) if not server.running: # pragma: no cover raise TrameJupyterServerDownError elif not server.running: raise TrameServerDownError(name) # Initialize app viewer = initialize( server, plotter, mode=mode, default_server_rendering=default_server_rendering, collapse_menu=collapse_menu, add_menu=add_menu, add_menu_items=add_menu_items, ) if jupyter_extension_enabled: # pragma: no cover from trame_client.ui.core import iframe_url_builder_jupyter_extension # noqa: PLC0415 iframe_attrs = iframe_url_builder_jupyter_extension(viewer.layout) src = iframe_attrs['src'] else: # TODO: The build_url function could possibly be replaced by # trame's upstream url builders in trame_client.ui.core iframe_attrs = {} src = build_url( server, ui=plotter._id_name, server_proxy_enabled=server_proxy_enabled, server_proxy_prefix=server_proxy_prefix, host=kwargs.get('host', 'localhost'), protocol=kwargs.get('protocol', 'http'), ) if callable(handler): return handler(viewer, src, iframe_attrs=iframe_attrs, **kwargs) return Widget(viewer, src, iframe_attrs=iframe_attrs, **kwargs) def elegantly_launch(*args, **kwargs): # numpydoc ignore=PR01 """Elegantly launch the Trame server without await. This provides a mechanism to launch the Trame Jupyter backend in a way that does not require users to await the call. This is a thin wrapper of :func:`launch_server() `. Returns ------- trame_server.core.Server The launched trame server. Warnings -------- This uses `nest_asyncio `_ which patches the standard lib `asyncio` package and may have unintended consequences for some uses cases. We advise strongly to make sure PyVista's Jupyter backend is not set to use Trame when not in a Jupyter environment. """ try: import nest_asyncio # noqa: PLC0415 except ImportError: msg = ( 'Please install `nest_asyncio` to automagically launch the trame server ' 'without await. Or, to avoid `nest_asynctio` run:\n\n' 'from pyvista.trame.jupyter import launch_server\n' 'await launch_server().ready' ) raise ImportError(msg) async def launch_it(): await launch_server(*args, **kwargs).ready # Basically monkey patches asyncio to support this nest_asyncio.apply() return asyncio.run(launch_it())