Source code for napari.utils._testsupport

import gc
import os
import sys
import warnings
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import patch
from weakref import WeakSet

import pytest

if TYPE_CHECKING:
    from pytest import FixtureRequest  # noqa: PT013

_SAVE_GRAPH_OPNAME = '--save-leaked-object-graph'


def _empty(*_, **__):
    """Empty function for mocking"""


def pytest_addoption(parser):
    parser.addoption(
        '--show-napari-viewer',
        action='store_true',
        default=False,
        help="don't show viewer during tests",
    )

    parser.addoption(
        _SAVE_GRAPH_OPNAME,
        action='store_true',
        default=False,
        help="Try to save a graph of leaked object's reference (need objgraph"
        'and graphviz installed',
    )


COUNTER = 0


def fail_obj_graph(Klass):  # pragma: no cover
    """
    Fail is a given class _instances weakset is non empty and print the object graph.
    """

    try:
        import objgraph
    except ModuleNotFoundError:
        return

    if len(Klass._instances) != 0:
        global COUNTER
        COUNTER += 1
        import gc

        leaked_objects_count = len(Klass._instances)

        gc.collect()
        file_path = Path(
            f'{Klass.__name__}-leak-backref-graph-{COUNTER}.pdf'
        ).absolute()
        objgraph.show_backrefs(
            list(Klass._instances),
            max_depth=20,
            filename=str(file_path),
        )

        Klass._instances.clear()

        assert file_path.exists()

        # DO not remove len, this can break as C++ obj are gone, but python objects
        # still hang around and _repr_ would crash.
        pytest.fail(
            f'Test run fail with leaked {leaked_objects_count} instances of {Klass}.'
            f'The object graph is saved in {file_path}.'
            f'{len(Klass._instances)} objects left after cleanup'
        )


@pytest.fixture
def napari_plugin_manager(monkeypatch):
    """A napari plugin manager that blocks discovery by default.

    Note you can still use `napari_plugin_manager.register()` to directly
    register a plugin module, class or dict for testing.

    Or, to re-enable global discovery, use:
    `napari_plugin_manager.discovery_blocker.stop()`
    """
    import napari
    import napari.plugins.io
    from napari.plugins._plugin_manager import NapariPluginManager

    pm = NapariPluginManager()

    # make it so that internal requests for the plugin_manager
    # get this test version for the duration of the test.
    monkeypatch.setattr(napari.plugins, 'plugin_manager', pm)
    monkeypatch.setattr(napari.plugins.io, 'plugin_manager', pm)
    with suppress(AttributeError):
        monkeypatch.setattr(napari._qt.qt_main_window, 'plugin_manager', pm)
    # prevent discovery of plugins in the environment
    # you can still use `pm.register` to explicitly register something.
    pm.discovery_blocker = patch.object(pm, 'discover')
    pm.discovery_blocker.start()
    pm._initialize()  # register our builtins
    yield pm
    pm.discovery_blocker.stop()


GCPASS = 0


@pytest.fixture(autouse=True)
def _clean_themes():
    from napari.utils import theme

    themes = set(theme.available_themes())
    yield
    for name in theme.available_themes():
        if name not in themes:
            del theme._themes[name]


@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    # https://docs.pytest.org/en/latest/example/simple.html#making-test-result-information-available-in-fixtures
    # execute all other hooks to obtain the report object
    outcome = yield
    rep = outcome.get_result()

    # set a report attribute for each phase of a call, which can
    # be "setup", "call", "teardown"

    setattr(item, f'rep_{rep.when}', rep)


@pytest.fixture
def mock_app():
    """Mock clean 'test_app' `NapariApplication` instance.

    This fixture must be used whenever `napari._app_model.get_app()` is called to
    return a 'test_app' `NapariApplication` instead of the 'napari'
    `NapariApplication`. The `make_napari_viewer` fixture is already equipped with
    a `mock_app`.

    Note that `NapariApplication` registers app-model actions.
    If this is not desired, please create a clean
    `app_model.Application` in the test.

    It does not register Qt related actions, providers or processors, which is done
    via `init_qactions()`. Nor does it register plugins, done via `_initialize_plugins`.
    If these are required, the `make_napari_viewer` fixture can be used, which
    will register ALL actions, providers and processors and register plugins.
    It will also automatically clear the lru cache.

    Alternatively, you can specifically run `init_qactions()` or
    `_initialize_plugins` within the test, ensuring that you `cache_clear()`
    first.
    """
    from app_model import Application

    from napari._app_model._app import NapariApplication, _napari_names

    app = NapariApplication('test_app')
    app.injection_store.namespace = _napari_names
    with patch.object(NapariApplication, 'get_app', return_value=app):
        try:
            yield app
        finally:
            Application.destroy('test_app')


[docs] @pytest.fixture def make_napari_viewer( qtbot, request: 'FixtureRequest', mock_app, napari_plugin_manager, monkeypatch, ): """A pytest fixture function that creates a napari viewer for use in testing. This fixture will take care of creating a viewer and cleaning up at the end of the test. When using this function, it is **not** necessary to use a `qtbot` fixture, nor should you do any additional cleanup (such as using `qtbot.addWidget` or calling `viewer.close()`) at the end of the test. Duplicate cleanup may cause an error. To use this fixture as a function in your tests: def test_something_with_a_viewer(make_napari_viewer): # `make_napari_viewer` takes any keyword arguments that napari.Viewer() takes viewer = make_napari_viewer() It accepts all the same arguments as `napari.Viewer`, notably `show` which should be set to `True` for tests that require the `Viewer` to be visible (e.g., tests that check aspects of the Qt window or layer rendering). It also accepts the following test-related parameters: ViewerClass : Type[napari.Viewer], optional Override the viewer class being used. By default, will use napari.viewer.Viewer strict_qt : bool or str, optional If True, a check will be performed after test cleanup to make sure that no top level widgets were created and *not* cleaned up during the test. If the string "raise" is provided, an AssertionError will be raised. Otherwise a warning is emitted. By default, this is False unless the test is being performed within the napari package. This can be made globally true by setting the 'NAPARI_STRICT_QT' environment variable. block_plugin_discovery : bool, optional Block discovery of non-builtin plugins. Note: plugins can still be manually registered by using the 'napari_plugin_manager' fixture and the `napari_plugin_manager.register()` method. By default, True. Examples -------- >>> def test_adding_shapes(make_napari_viewer): ... viewer = make_napari_viewer() ... viewer.add_shapes() ... assert len(viewer.layers) == 1 >>> def test_something_with_plugins(make_napari_viewer): ... viewer = make_napari_viewer(block_plugin_discovery=False) >>> def test_something_with_strict_qt_tests(make_napari_viewer): ... viewer = make_napari_viewer(strict_qt=True) """ from qtpy.QtWidgets import QApplication, QWidget from napari import Viewer from napari._qt._qapp_model.qactions import init_qactions from napari._qt.qt_viewer import QtViewer from napari.plugins import _initialize_plugins from napari.settings import get_settings global GCPASS GCPASS += 1 if GCPASS % 50 == 0: gc.collect() else: gc.collect(1) _do_not_inline_below = len(QtViewer._instances) # # do not inline to avoid pytest trying to compute repr of expression. # # it fails if C++ object gone but not Python object. if request.config.getoption(_SAVE_GRAPH_OPNAME): fail_obj_graph(QtViewer) QtViewer._instances.clear() assert _do_not_inline_below == 0, ( 'Some instance of QtViewer is not properly cleaned in one of previous test. For easier debug one may ' f'use {_SAVE_GRAPH_OPNAME} flag for pytest to get graph of leaked objects. If you use qtbot (from pytest-qt)' ' to clean Qt objects after test you may need to switch to manual clean using ' '`deleteLater()` and `qtbot.wait(50)` later.' ) settings = get_settings() settings.reset() _initialize_plugins.cache_clear() init_qactions.cache_clear() viewers: WeakSet[Viewer] = WeakSet() # may be overridden by using the parameter `strict_qt` _strict = False initial = QApplication.topLevelWidgets() prior_exception = getattr(sys, 'last_value', None) is_internal_test = request.module.__name__.startswith('napari.') # disable thread for status checker monkeypatch.setattr( 'napari._qt.threads.status_checker.StatusChecker.start', _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, strict_qt=None, block_plugin_discovery=True, **model_kwargs, ): if strict_qt is None: strict_qt = is_internal_test or os.getenv('NAPARI_STRICT_QT') nonlocal _strict _strict = strict_qt if not block_plugin_discovery: napari_plugin_manager.discovery_blocker.stop() should_show = request.config.getoption('--show-napari-viewer') model_kwargs['show'] = model_kwargs.pop('show', should_show) viewer = ViewerClass(*model_args, **model_kwargs) viewers.add(viewer) return viewer yield actual_factory # Some tests might have the viewer closed, so this call will not be able # to access the window. with suppress(AttributeError): get_settings().reset() # close viewers, but don't saving window settings while closing for viewer in viewers: if hasattr(viewer.window, '_qt_window'): with patch.object( viewer.window._qt_window, '_save_current_window_settings' ): viewer.close() else: viewer.close() if GCPASS % 50 == 0 or len(QtViewer._instances): gc.collect() else: gc.collect(1) if request.config.getoption(_SAVE_GRAPH_OPNAME): fail_obj_graph(QtViewer) if request.node.rep_call.failed: # IF test failed do not check for leaks QtViewer._instances.clear() _do_not_inline_below = len(QtViewer._instances) QtViewer._instances.clear() # clear to prevent fail of next test # do not inline to avoid pytest trying to compute repr of expression. # it fails if C++ object gone but not Python object. assert ( _do_not_inline_below == 0 ), f'{request.config.getoption(_SAVE_GRAPH_OPNAME)}, {_SAVE_GRAPH_OPNAME}' # only check for leaked widgets if an exception was raised during the test, # and "strict" mode was used. if _strict and getattr(sys, 'last_value', None) is prior_exception: QApplication.processEvents() leak = set(QApplication.topLevelWidgets()).difference(initial) # still not sure how to clean up some of the remaining vispy # vispy.app.backends._qt.CanvasBackendDesktop widgets... if any(n.__class__.__name__ != 'CanvasBackendDesktop' for n in leak): # just a warning... but this can be converted to test errors # in pytest with `-W error` msg = f"""The following Widgets leaked!: {leak}. Note: If other tests are failing it is likely that widgets will leak as they will be (indirectly) attached to the tracebacks of previous failures. Please only consider this an error if all other tests are passing. """ # Explanation notes on the above: While we are indeed looking at the # difference in sets of widgets between before and after, new object can # still not be garbage collected because of it. # in particular with VisPyCanvas, it looks like if a traceback keeps # contains the type, then instances are still attached to the type. # I'm not too sure why this is the case though. if _strict == 'raise': raise AssertionError(msg) else: warnings.warn(msg)
@pytest.fixture def make_napari_viewer_proxy(make_napari_viewer, monkeypatch): """Fixture that returns a function for creating a napari viewer wrapped in proxy. Use in the same way like `make_napari_viewer` fixture. Parameters ---------- make_napari_viewer : fixture The make_napari_viewer fixture. Returns ------- function A function that creates a napari viewer. """ from napari.utils._proxies import PublicOnlyProxy proxies = [] def actual_factory(*model_args, ensure_main_thread=True, **model_kwargs): monkeypatch.setenv( 'NAPARI_ENSURE_PLUGIN_MAIN_THREAD', str(ensure_main_thread) ) viewer = make_napari_viewer(*model_args, **model_kwargs) proxies.append(PublicOnlyProxy(viewer)) return proxies[-1] proxies.clear() return actual_factory @pytest.fixture def MouseEvent(): """Create a subclass for simulating vispy mouse events. Returns ------- Event : Type A new dataclass named Event that can be used to create an object with fields "type" and "is_dragging". """ @dataclass class Event: type: str position: tuple[float] is_dragging: bool = False dims_displayed: tuple[int] = (0, 1) dims_point: list[float] = None view_direction: list[int] = None pos: list[int] = (0, 0) button: int = None handled: bool = False return Event