diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 7659bc5..1be5d34 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -56,7 +56,7 @@ jobs: - name: Test with tox uses: aganders3/headless-gui@v2 with: - run: python -m tox -vv + run: python -m tox -v env: PLATFORM: ${{ matrix.platform }} PYVISTA_OFF_SCREEN: True diff --git a/README.md b/README.md index 01acf65..3fd84cf 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ [![License](https://img.shields.io/pypi/l/napari-console.svg?color=green)](https://github.com/napari/napari-console/raw/master/LICENSE) [![PyPI](https://img.shields.io/pypi/v/napari-console.svg?color=green)](https://pypi.org/project/napari-console) [![Python Version](https://img.shields.io/pypi/pyversions/napari-console.svg?color=green)](https://python.org) -[![tests](https://github.com/sofroniewn/napari-console/workflows/tests/badge.svg)](https://github.com/sofroniewn/napari-console/actions) -[![codecov](https://codecov.io/gh/sofroniewn/napari-console/branch/master/graph/badge.svg)](https://codecov.io/gh/sofroniewn/napari-console) +[![tests](https://github.com/napari/napari-console/workflows/tests/badge.svg)](https://github.com/napari/napari-console/actions) +[![codecov](https://codecov.io/gh/napari/napari-console/branch/main/graph/badge.svg)](https://codecov.io/gh/napari/napari-console) A plugin that adds a console to napari @@ -20,6 +20,41 @@ and review the napari docs for plugin developers: https://napari.org/docs/plugins/index.html --> +## Local variables + +In napari-console 0.0.8 and earlier, the console `locals()` namespace only +contained a reference to the napari viewer that enclosed the console. + +Since version 0.0.9, it instead contains everything in the enclosing frame that +called napari. That is, if your Python code is: + +```python +import napari +import numpy as np +from scipy import ndimage as ndi + +image = np.random.random((500, 500)) +labels = ndi.label(image > 0.7)[0] + +viewer, image_layer = napari.imshow(image) +labels_layer = viewer.add_labels(labels) + +napari.run() +``` + +Then the napari console will have the variables `np`, `napari`, `ndi`, `image`, +`labels`, `viewer`, `image_layer`, and `labels_layer` in its namespace. + +This is implemented by inspecting the Python stack when the console is first +instantiated, finding the first frame that is outside of the `napari_console`, +`napari`, and `in_n_out` modules, and passing the variables in the frame's +`f_locals` and `f_globals` to the console namespace. + +If you want to disable this behavior (for example, because you are embedding +napari and the console within some larger application), you can add +`NAPARI_EMBED=1` to your environment variables before instantiating the +console. + ## Installation You can install `napari-console` via [pip]: diff --git a/napari_console/_tests/test_qt_console.py b/napari_console/_tests/test_qt_console.py index 242c53b..ea20a81 100644 --- a/napari_console/_tests/test_qt_console.py +++ b/napari_console/_tests/test_qt_console.py @@ -73,3 +73,27 @@ def control_has_focus(): ), "underlying QTextEdit widget never received focus" qtbot.waitUntil(control_has_focus) + + +def test_console_pass_variable(make_test_viewer, monkeypatch): + monkeypatch.setattr("napari_console.qt_console._PREF_LIST", ["napari.", "in_n_out."]) + variable1 = True + variable2 = "sample text" + + viewer = make_test_viewer() + console = viewer.window._qt_viewer.console + assert console.shell.user_ns['variable1'] == variable1 + assert console.shell.user_ns['variable2'] == variable2 + + assert "mock" in console.shell.user_ns + + +def test_console_disable_pass_variable(make_test_viewer, monkeypatch): + monkeypatch.setattr("napari_console.qt_console._PREF_LIST", ["napari.", "in_n_out."]) + monkeypatch.setitem(globals(), "NAPARI_EMBED", True) + variable3 = True + + viewer = make_test_viewer() + console = viewer.window._qt_viewer.console + assert locals()['variable3'] == variable3 + assert "variable3" not in console.shell.user_ns diff --git a/napari_console/qt_console.py b/napari_console/qt_console.py index de39b05..2033755 100644 --- a/napari_console/qt_console.py +++ b/napari_console/qt_console.py @@ -2,6 +2,9 @@ import sys import warnings +from typing import Optional +from types import FrameType + from ipykernel.connect import get_connection_file from ipykernel.inprocess.ipkernel import InProcessInteractiveShell from ipykernel.zmqshell import ZMQInteractiveShell @@ -13,6 +16,11 @@ from qtpy.QtGui import QColor +from napari.utils.naming import CallerFrame + + +_PREF_LIST = ["napari.", "napari_console.", "in_n_out."] + def str_to_rgb(arg): """Convert an rgb string 'rgb(x,y,z)' to a list of ints [x,y,z].""" @@ -55,13 +63,18 @@ def str_to_rgb(arg): asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy()) + + class QtConsole(RichJupyterWidget): - """Qt view for the console, an integrated iPython terminal in napari. + """Qt view for the console, an integrated IPython terminal in napari. + + This Qt console will automatically embed the first caller namespace when + not in napari by walking up the frame. Parameters ---------- - user_variables : dict - Dictionary of user variables to declare in console name space. + max_depth : int + maximum number of frames to consider being outside of napari. Attributes ---------- @@ -73,11 +86,15 @@ class QtConsole(RichJupyterWidget): Shell for the kernel if it exists, None otherwise. """ - def __init__(self, viewer: 'napari.viewer.Viewer'): + min_depth: Optional[int] + + def __init__(self, viewer: "napari.viewer.Viewer", *, min_depth=1): super().__init__() self.viewer = viewer + self.min_depth = min_depth + # Connect theme update self.viewer.events.theme.connect(self._update_theme) user_variables = {'viewer': self.viewer} @@ -128,6 +145,7 @@ def __init__(self, viewer: 'napari.viewer.Viewer'): raise ValueError( 'ipython shell not recognized; ' f'got {type(shell)}' ) + self._capture() # Add any user variables user_variables = user_variables or {} self.push(user_variables) @@ -140,6 +158,29 @@ def __init__(self, viewer: 'napari.viewer.Viewer'): # TODO: Try to get console from jupyter to run without a shift click # self.execute_on_complete_input = True + def _in_napari(self, n: int, frame: FrameType): + """ + Predicates that return Wether we are in napari by looking + at: + 1) the frames modules names: + 2) the min_depth + """ + # in-n-out is used in napari for dependency injection. + if n <= self.min_depth: + return True + for pref in _PREF_LIST: + if frame.f_globals.get("__name__", "").startswith(pref): + return True + return False + + def _capture(self): + """ + Capture variable from first enclosing scope that is not napari + """ + with CallerFrame(self._in_napari) as c: + if c.frame.f_globals.get("__name__", "") != "__main__" and "NAPARI_EMBED" not in c.frame.f_globals: + self.push(dict(c.namespace)) + def _update_theme(self, event=None): """Update the napari GUI theme.""" from napari.utils.theme import get_theme, template