Skip to content

Commit

Permalink
Capture caller frame of napari.run() when starting the console. (#18)
Browse files Browse the repository at this point in the history
Closes napari/napari#4098

Replaces napari/napari#4140, and see discussion there as well

Needs napari/napari#4212

---------

Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>
Co-authored-by: Juan Nunez-Iglesias <jni@fastmail.com>
  • Loading branch information
3 people authored Sep 19, 2024
1 parent 29b68a3 commit 2148590
Show file tree
Hide file tree
Showing 4 changed files with 107 additions and 7 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test_and_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]:
Expand Down
24 changes: 24 additions & 0 deletions napari_console/_tests/test_qt_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
49 changes: 45 additions & 4 deletions napari_console/qt_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]."""
Expand Down Expand Up @@ -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
----------
Expand All @@ -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}
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down

0 comments on commit 2148590

Please sign in to comment.