Repository: napari/napari

Issue:
can no longer use breakpoints in napari qt tests
### 🐛 Bug Report

Was just trying to debug something for someone and noticed that it's no longer possible to use breakpoints inside of tests in the napari repo after #5890


### 💡 Steps to Reproduce

 
```python
def test_thing(qapp):
    breakpoint()
```

nothing happens... but if you comment out the `mock_console` fixture added here, you can debug again

https://github.com/napari/napari/blob/c61e9230bd01cbb2f7ce3fe69d13e4b045ba2d06/napari/conftest.py#L772-L780


### 💡 Expected Behavior

_No response_

### 🌎 Environment

❯ napari --info
napari: 0.5.0a2.dev427+gb664fa06.d20231105
Platform: macOS-13.6.1-x86_64-i386-64bit
System: MacOS 13.6.1
Python: 3.10.13 | packaged by conda-forge | (main, Oct 26 2023, 18:09:20) [Clang 16.0.6 ]
Qt: 6.6.0
PyQt6:
NumPy: 1.26.1
SciPy: 1.11.3
Dask: 2023.10.1
VisPy: 0.14.1
magicgui: 0.8.0
superqt: 0.6.1
in-n-out: 0.1.9
app-model: 0.2.3.dev9+g0717900
npe2: 0.7.3

### 💡 Additional Context

_No response_


Discussion:
Hm. Nice spot. I will investigate it more. 
@tlambert03 Could you exactly describe how exactly you run tests? I'm almost sure that I meet similar problem somehow in the past, but cannot reproduce it on Linux and macOS. But maybe I run the test differently than you? 
pytest.  but let me try some stuff
pytest from console, from vscode? 
console
```
❯ pip list
Package                       Version                  Editable project location
----------------------------- ------------------------ -----------------------------
alabaster                     0.7.13
app-model                     0.2.2
appdirs                       1.4.4
appnope                       0.1.3
asciitree                     0.3.3
asttokens                     2.4.1
attrs                         23.1.0
Babel                         2.13.1
black                         23.11.0
build                         1.0.3
cachey                        0.2.1
certifi                       2023.7.22
cfgv                          3.4.0
charset-normalizer            3.3.2
check-manifest                0.49
click                         8.1.7
cloudpickle                   3.0.0
comm                          0.2.0
contourpy                     1.2.0
coverage                      7.3.2
cycler                        0.12.1
dask                          2023.10.1
debugpy                       1.8.0
decorator                     5.1.1
distlib                       0.3.7
docstring-parser              0.15
docutils                      0.17.1
executing                     2.0.1
fancycompleter                0.9.1
fasteners                     0.19
filelock                      3.13.1
fonttools                     4.44.0
freetype-py                   2.4.0
fsspec                        2023.10.0
HeapDict                      1.0.1
hsluv                         5.0.4
hypothesis                    6.88.3
identify                      2.5.31
idna                          3.4
imageio                       2.32.0
imagesize                     1.4.1
importlib-metadata            6.8.0
in-n-out                      0.1.9
iniconfig                     2.0.0
ipykernel                     6.26.0
ipython                       8.17.2
jaraco.classes                3.3.0
jedi                          0.19.1
Jinja2                        3.1.2
jsonschema                    4.19.2
jsonschema-specifications     2023.7.1
jupyter_client                8.6.0
jupyter_core                  5.5.0
keyring                       24.2.0
kiwisolver                    1.4.5
lazy_loader                   0.3
locket                        1.0.0
lxml                          4.9.3
magicgui                      0.8.0
markdown-it-py                2.2.0
MarkupSafe                    2.1.3
matplotlib                    3.8.1
matplotlib-inline             0.1.6
mdurl                         0.1.2
ml-dtypes                     0.3.1
more-itertools                10.1.0
mypy                          1.6.1
mypy-extensions               1.0.0
napari                        0.5.0a2.dev431+gc61e9230 /Users/talley/dev/self/napari
napari-console                0.0.9
napari-plugin-engine          0.2.0
napari-plugin-manager         0.1.0a2
napari-svg                    0.1.10
nest-asyncio                  1.5.8
networkx                      3.2.1
nh3                           0.2.14
nodeenv                       1.8.0
npe2                          0.7.3
numcodecs                     0.12.1
numpy                         1.26.1
numpydoc                      1.5.0
packaging                     23.2
pandas                        2.1.2
parso                         0.8.3
partd                         1.4.1
pathspec                      0.11.2
pdbpp                         0.10.3
pexpect                       4.8.0
Pillow                        10.0.1
Pint                          0.22
pip                           23.3.1
pkginfo                       1.9.6
platformdirs                  3.11.0
pluggy                        1.3.0
pooch                         1.8.0
pre-commit                    3.5.0
pretend                       1.0.9
prompt-toolkit                3.0.39
psutil                        5.9.6
psygnal                       0.9.4
ptyprocess                    0.7.0
pure-eval                     0.2.2
pyconify                      0.1.6
pydantic                      1.10.13
Pygments                      2.16.1
PyOpenGL                      3.1.7
pyparsing                     3.1.1
pyproject_hooks               1.0.0
PyQt5                         5.15.10
PyQt5-Qt5                     5.15.11
PyQt5-sip                     12.13.0
pyrepl                        0.9.0
pytest                        7.4.3
pytest-pretty                 1.2.0
pytest-qt                     4.2.0
python-dateutil               2.8.2
pytz                          2023.3.post1
PyYAML                        6.0.1
pyzmq                         25.1.1
qtconsole                     5.5.0
QtPy                          2.4.1
readme-renderer               42.0
referencing                   0.30.2
requests                      2.31.0
requests-toolbelt             1.0.0
rfc3986                       2.0.0
rich                          13.3.1
rpds-py                       0.12.0
ruff                          0.1.5
scikit-image                  0.22.0
scipy                         1.11.3
setuptools                    68.2.2
six                           1.16.0
snowballstemmer               2.2.0
sortedcontainers              2.4.0
Sphinx                        4.5.0
sphinxcontrib-applehelp       1.0.4
sphinxcontrib-devhelp         1.0.2
sphinxcontrib-htmlhelp        2.0.1
sphinxcontrib-jsmath          1.0.1
sphinxcontrib-qthelp          1.0.3
sphinxcontrib-serializinghtml 1.1.5
stack-data                    0.6.3
superqt                       0.6.1
tensorstore                   0.1.48
tifffile                      2023.9.26
tomli_w                       1.0.0
toolz                         0.12.0
tornado                       6.3.3
tqdm                          4.66.1
traitlets                     5.13.0
twine                         4.0.2
typer                         0.9.0
typing_extensions             4.8.0
tzdata                        2023.3
urllib3                       2.0.7
virtualenv                    20.24.6
vispy                         0.14.1
wcwidth                       0.2.9
wheel                         0.41.3
wmctrl                        0.5
wrapt                         1.16.0
xarray                        2023.10.1
zarr                          2.16.1
zipp                          3.17.0

…/dev/self/napari on  main [!?] via 🅒 napari
❯ napari --info
napari: 0.5.0a2.dev431+gc61e9230
Platform: macOS-14.1-arm64-arm-64bit
System: MacOS 14.1
Python: 3.11.6 | packaged by conda-forge | (main, Oct  3 2023, 10:37:07) [Clang 15.0.7 ]
Qt: 5.15.11
PyQt5: 5.15.10
NumPy: 1.26.1
SciPy: 1.11.3
Dask: 2023.10.1
VisPy: 0.14.1
magicgui: 0.8.0
superqt: 0.6.1
in-n-out: 0.1.9
app-model: 0.2.2
npe2: 0.7.3

OpenGL:
  - GL version:  2.1 Metal - 88
  - MAX_TEXTURE_SIZE: 16384

Screens:
  - screen 1: resolution 2560x1440, scale 2.0

Settings path:
  - /Users/talley/Library/Application Support/napari/napari_d78cbfa548b6d534518a836a39b360f01fdfb72d/settings.yaml
Plugins:
  - napari: 0.5.0a2.dev431+gc61e9230 (77 contributions)
  - napari-console: 0.0.9 (0 contributions)
  - napari-svg: 0.1.10 (2 contributions)

…/dev/self/napari on  main [!?] via 🅒 napari took 2.7s
❯ cat napari/_tests/test_thing.py
def test_think(qapp):
    breakpoint()

…/dev/self/napari on  main [!?] via 🅒 napari
❯ pytest napari/_tests/test_thing.py
=========================================== test session starts ============================================
platform darwin -- Python 3.11.6, pytest-7.4.3, pluggy-1.3.0
PyQt5 5.15.10 -- Qt runtime 5.15.11 -- Qt compiled 5.15.11
rootdir: /Users/talley/dev/self/napari
configfile: pyproject.toml
plugins: qt-4.2.0, napari-plugin-engine-0.2.0, hypothesis-6.88.3, napari-0.5.0a2.dev431+gc61e9230, pretty-1.2.0, npe2-0.7.3
collected 1 item

napari/_tests/test_thing.py .                                                                         [1/1]

============================================= warnings summary =============================================
../../../miniforge3/envs/napari/lib/python3.11/site-packages/_pytest/config/__init__.py:1373
  /Users/talley/miniforge3/envs/napari/lib/python3.11/site-packages/_pytest/config/__init__.py:1373: PytestConfigWarning: Unknown config option: pystack_threshold

    self._warn_or_fail_if_strict(f"Unknown config option: {key}\n")

napari/_tests/test_thing.py::test_think
  /Users/talley/miniforge3/envs/napari/lib/python3.11/site-packages/jupyter_client/connect.py:22: DeprecationWarning: Jupyter is migrating its paths to use standard platformdirs
  given by the platformdirs library.  To remove this warning and
  see the appropriate new directories, set the environment variable
  `JUPYTER_PLATFORM_DIRS=1` and then run `jupyter --paths`.
  The use of platformdirs will be the default in `jupyter_core` v6
    from jupyter_core.paths import jupyter_data_dir, jupyter_runtime_dir, secure_write

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================================== slowest 10 durations ===========================================
0.28s setup    napari/_tests/test_thing.py::test_think
0.06s call     napari/_tests/test_thing.py::test_think

(1 durations < 0.005s hidden.  Use -vv to show these durations.)
Results (0.35s):
         1 passed
         2 warnings
```
also, I don't think it's too environment/platform specific.  I'm on a different computer than I was when I originally found it.  and just created a fresh environment and immediately saw it.
It is not, but I just start tests from IDE and it works differently. 

I found the most probable source of the bug. The import of QtConsole triggers the import `debugpy` that overwrites the default breakpoint handler. This fixture imports QtConsole to prevent its spawning (as there is one global ipython session - that creates problems with test in another place). 

@Carreau Maybe you have some insight? 

rather than importing and patching `napari_console.QtConsole` have you considered just restricting the patch to napari itself, by patching `QtViewer._console`?
> rather than importing and patching `napari_console.QtConsole` have you considered just restricting the patch to napari itself, by patching `QtViewer._console`?

As I remember there was no such discussion. But one needs to revisit the QtCosole test and check how to mock it. 
> But one needs to revisit the QtCosole test and check how to mock it.

What I mean is that I'm pretty sure that the only place in the whole napari codebase that qtconsole or napari_console are ever imported are behind the `QtViewer.console` property.  So, it would appear that you could probably use a similar mock to what you're currently using, but rather than greedily importing `napari_console` and `qtconsole` and using `patch("napari_console.QtConsole", FakeQtConsole)` up front just in case the `QtViewer.console` method happens to be called, you should `patch("QtViewer.property", ...)` itself.  That way, only tests the actually access `.console` need be affected by this mock (rather than all qapp tests)
Globally I agree. We did not think about this when this thing was implemented. I hope we will fix this shortly.

Resolving patch:
diff --git a/napari/_qt/qt_viewer.py b/napari/_qt/qt_viewer.py
--- a/napari/_qt/qt_viewer.py
+++ b/napari/_qt/qt_viewer.py
@@ -63,6 +63,7 @@
 from napari._vispy import VispyCanvas, create_vispy_layer  # isort:skip
 
 if TYPE_CHECKING:
+    from napari_console import QtConsole
     from npe2.manifest.contributions import WriterContribution
 
     from napari._qt.layer_controls import QtLayerControlsContainer
@@ -536,60 +537,75 @@ def console_backlog(self):
         """List: items to push to console when instantiated."""
         return self._console_backlog
 
-    @property
-    def console(self):
-        """QtConsole: iPython console terminal integrated into the napari GUI."""
-        if self._console is None:
-            try:
-                import numpy as np
+    def _get_console(self) -> Optional[QtConsole]:
+        """
+        Function for setup console.
 
-                # QtConsole imports debugpy that overwrites default breakpoint.
-                # It makes problems with debugging if you do not know this.
-                # So we do not want to overwrite it if it is already set.
-                breakpoint_handler = sys.breakpointhook
-                from napari_console import QtConsole
+        Returns
+        -------
 
-                sys.breakpointhook = breakpoint_handler
+        Notes
+        _____
+        extracted to separated function for simplify testing
 
-                import napari
+        """
+        try:
+            import numpy as np
 
-                with warnings.catch_warnings():
-                    warnings.filterwarnings("ignore")
-                    self.console = QtConsole(self.viewer)
-                    self.console.push(
-                        {'napari': napari, 'action_manager': action_manager}
-                    )
-                    with CallerFrame(_in_napari) as c:
-                        if c.frame.f_globals.get("__name__", "") == "__main__":
-                            self.console.push({"np": np})
-                    for i in self.console_backlog:
-                        # recover weak refs
-                        self.console.push(
-                            {
-                                k: self._unwrap_if_weakref(v)
-                                for k, v in i.items()
-                                if self._unwrap_if_weakref(v) is not None
-                            }
-                        )
-                    self._console_backlog = []
-            except ModuleNotFoundError:
-                warnings.warn(
-                    trans._(
-                        'napari-console not found. It can be installed with'
-                        ' "pip install napari_console"'
-                    ),
-                    stacklevel=1,
-                )
-                self._console = None
-            except ImportError:
-                traceback.print_exc()
-                warnings.warn(
-                    trans._(
-                        'error importing napari-console. See console for full error.'
-                    ),
-                    stacklevel=1,
+            # QtConsole imports debugpy that overwrites default breakpoint.
+            # It makes problems with debugging if you do not know this.
+            # So we do not want to overwrite it if it is already set.
+            breakpoint_handler = sys.breakpointhook
+            from napari_console import QtConsole
+
+            sys.breakpointhook = breakpoint_handler
+
+            import napari
+
+            with warnings.catch_warnings():
+                warnings.filterwarnings("ignore")
+                console = QtConsole(self.viewer)
+                console.push(
+                    {'napari': napari, 'action_manager': action_manager}
                 )
-                self._console = None
+                with CallerFrame(_in_napari) as c:
+                    if c.frame.f_globals.get("__name__", "") == "__main__":
+                        console.push({"np": np})
+                for i in self.console_backlog:
+                    # recover weak refs
+                    console.push(
+                        {
+                            k: self._unwrap_if_weakref(v)
+                            for k, v in i.items()
+                            if self._unwrap_if_weakref(v) is not None
+                        }
+                    )
+                return console
+        except ModuleNotFoundError:
+            warnings.warn(
+                trans._(
+                    'napari-console not found. It can be installed with'
+                    ' "pip install napari_console"'
+                ),
+                stacklevel=1,
+            )
+            return None
+        except ImportError:
+            traceback.print_exc()
+            warnings.warn(
+                trans._(
+                    'error importing napari-console. See console for full error.'
+                ),
+                stacklevel=1,
+            )
+            return None
+
+    @property
+    def console(self):
+        """QtConsole: iPython console terminal integrated into the napari GUI."""
+        if self._console is None:
+            self.console = self._get_console()
+            self._console_backlog = []
         return self._console
 
     @console.setter
diff --git a/napari/conftest.py b/napari/conftest.py
--- a/napari/conftest.py
+++ b/napari/conftest.py
@@ -39,7 +39,7 @@ def get_reader(path):
 from multiprocessing.pool import ThreadPool
 from pathlib import Path
 from typing import TYPE_CHECKING, Optional
-from unittest.mock import Mock, patch
+from unittest.mock import patch
 from weakref import WeakKeyDictionary
 
 from npe2 import PackageMetadata
@@ -61,7 +61,6 @@ def get_reader(path):
 from napari.components import LayerList
 from napari.layers import Image, Labels, Points, Shapes, Vectors
 from napari.utils.misc import ROOT_DIR
-from napari.viewer import Viewer
 
 if TYPE_CHECKING:
     from npe2._pytest_plugin import TestPluginManager
@@ -404,38 +403,6 @@ def single_threaded_executor():
     executor.shutdown()
 
 
-@pytest.fixture()
-def mock_console(request):
-    """Mock the qtconsole to avoid starting an interactive IPython session.
-    In-process IPython kernels can interfere with other tests and are difficult
-    (impossible?) to shutdown.
-
-    This fixture is configured to be applied automatically to tests unless they
-    use the `enable_console` marker. It's not autouse to avoid use on headless
-    tests (without Qt); instead it's enabled in `pytest_runtest_setup`.
-    """
-    if "enable_console" in request.keywords:
-        yield
-        return
-
-    from napari_console import QtConsole
-    from qtconsole.rich_jupyter_widget import RichJupyterWidget
-
-    class FakeQtConsole(RichJupyterWidget):
-        def __init__(self, viewer: Viewer):
-            super().__init__()
-            self.viewer = viewer
-            self.kernel_client = None
-            self.kernel_manager = None
-
-        _update_theme = Mock()
-        push = Mock()
-        closeEvent = QtConsole.closeEvent
-
-    with patch("napari_console.QtConsole", FakeQtConsole):
-        yield
-
-
 @pytest.fixture(autouse=True)
 def _mock_app():
     """Mock clean 'test_app' `NapariApplication` instance.
@@ -775,7 +742,6 @@ def pytest_runtest_setup(item):
                 "dangling_qanimations",
                 "dangling_qthreads",
                 "dangling_qtimers",
-                "mock_console",
             ]
         )
 
diff --git a/napari/utils/_testsupport.py b/napari/utils/_testsupport.py
--- a/napari/utils/_testsupport.py
+++ b/napari/utils/_testsupport.py
@@ -173,7 +173,7 @@ def make_napari_viewer(
     >>> def test_something_with_strict_qt_tests(make_napari_viewer):
     ...     viewer = make_napari_viewer(strict_qt=True)
     """
-    from qtpy.QtWidgets import QApplication
+    from qtpy.QtWidgets import QApplication, QWidget
 
     from napari import Viewer
     from napari._qt._qapp_model.qactions import init_qactions
@@ -223,6 +223,17 @@ def make_napari_viewer(
         _empty,
     )
 
+    if "enable_console" not in request.keywords:
+
+        def _dummy_widget(*_):
+            w = QWidget()
+            w._update_theme = _empty
+            return w
+
+        monkeypatch.setattr(
+            "napari._qt.qt_viewer.QtViewer._get_console", _dummy_widget
+        )
+
     def actual_factory(
         *model_args,
         ViewerClass=Viewer,


Test patch:
diff --git a/napari/_tests/test_conftest_fixtures.py b/napari/_tests/test_conftest_fixtures.py
--- a/napari/_tests/test_conftest_fixtures.py
+++ b/napari/_tests/test_conftest_fixtures.py
@@ -4,9 +4,6 @@
 from qtpy.QtCore import QMutex, QThread, QTimer
 from superqt.utils import qdebounced
 
-from napari._qt.qt_viewer import QtViewer
-from napari.viewer import ViewerModel
-
 
 class _TestThread(QThread):
     def __init__(self) -> None:
@@ -52,11 +49,6 @@ def test_disable_qtimer(qtbot):
     assert not th.isRunning()
 
 
-def test_console_mock(qapp):
-    qt_viewer = QtViewer(ViewerModel())
-    assert qt_viewer.console.__class__.__name__ == "FakeQtConsole"
-
-
 @pytest.mark.usefixtures("disable_throttling")
 @patch("qtpy.QtCore.QTimer.start")
 def test_disable_throttle(start_mock):